From 8364f8c8f7a697472b7aab9263a5ba5aac9afb48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 14:21:06 +0800 Subject: [PATCH 01/11] feat(web-shell): add opt-in interactive browser terminal Adds a browser terminal to the web-shell right panel, backed by shared node-pty sessions exposed through an authenticated /terminal WebSocket route on the serve daemon. The feature is opt-in (QWEN_WEB_TERMINAL=1 plus bearer auth) and gated behind a web_terminal capability so default deployments never expose the entry point. Terminals are workspace-scoped: the daemon resolves the client workspace selector against the live registry, binds each PTY to that runtime's cwd and effective environment, and releases sessions when the workspace drains. Reconnects claim the same session id and replay bounded scrollback (including exit state) without duplicating output; controls resize/release ride text frames while PTY data stays binary. A session with no listeners is reclaimed after 15 minutes. Co-authored-by: Qwen-Coder --- docs/design/web-terminal-lifecycle.md | 18 + package-lock.json | 9 +- packages/cli/src/serve/acp-http/index.ts | 4 +- .../acp-http/workspace-qualified-acp.test.ts | 46 ++ packages/cli/src/serve/capabilities.ts | 1 + .../cli/src/serve/routes/terminal.test.ts | 481 ++++++++++++++++++ packages/cli/src/serve/routes/terminal.ts | 271 ++++++++++ packages/cli/src/serve/run-qwen-serve.ts | 13 + packages/cli/src/serve/server.test.ts | 7 + packages/cli/src/serve/server.ts | 28 + packages/core/src/index.ts | 1 + .../services/web-terminal-registry.test.ts | 394 ++++++++++++++ .../src/services/web-terminal-registry.ts | 321 ++++++++++++ packages/web-shell/client/App.test.tsx | 94 ++++ packages/web-shell/client/App.tsx | 33 ++ .../artifacts/ArtifactPanel.module.css | 11 + .../artifacts/ArtifactPanel.test.tsx | 95 ++++ .../components/artifacts/ArtifactPanel.tsx | 75 ++- .../terminal/TerminalPanel.test.tsx | 188 +++++++ .../components/terminal/TerminalPanel.tsx | 315 ++++++++++++ packages/web-shell/client/customization.tsx | 2 +- packages/web-shell/client/i18n.tsx | 4 + packages/web-shell/client/main.tsx | 2 +- packages/web-shell/package.json | 2 + packages/web-shell/vite.config.ts | 3 + 25 files changed, 2412 insertions(+), 6 deletions(-) create mode 100644 docs/design/web-terminal-lifecycle.md create mode 100644 packages/cli/src/serve/routes/terminal.test.ts create mode 100644 packages/cli/src/serve/routes/terminal.ts create mode 100644 packages/core/src/services/web-terminal-registry.test.ts create mode 100644 packages/core/src/services/web-terminal-registry.ts create mode 100644 packages/web-shell/client/components/terminal/TerminalPanel.test.tsx create mode 100644 packages/web-shell/client/components/terminal/TerminalPanel.tsx diff --git a/docs/design/web-terminal-lifecycle.md b/docs/design/web-terminal-lifecycle.md new file mode 100644 index 00000000000..83bd1ec3402 --- /dev/null +++ b/docs/design/web-terminal-lifecycle.md @@ -0,0 +1,18 @@ +# Web terminal lifecycle + +The browser terminal is a manually opened Web Shell surface. The host must add +the terminal right-panel action and the daemon must advertise the +`web_terminal` capability before the UI exposes the entry. WebSocket requests +use the daemon's shared Host, Origin, authentication and workspace trust gates. + +Each terminal is owned by one active, trusted workspace runtime. The daemon +resolves the client workspace selector against the live registry and supplies +that runtime's cwd and effective environment to the PTY. A terminal id cannot +be reclaimed through another workspace. + +Closing a terminal tab sends an explicit release control frame and kills the +PTY. Hiding the panel, switching tabs or losing the WebSocket only detaches the +client, so reconnect can replay the same session. A detached session is killed +after the idle timeout even if its command is still running. Exited sessions +replay both scrollback and exit state. Binary WebSocket frames contain UTF-8 +terminal input and output; text frames are reserved for control messages. diff --git a/package-lock.json b/package-lock.json index ae19ca2e4ac..03d23fa79b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10758,6 +10758,12 @@ "ws": "^8.16.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -10768,7 +10774,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", - "dev": true, "license": "MIT", "workspaces": [ "addons/*" @@ -32834,6 +32839,8 @@ "@datafe-open/markdown-chart-echarts": "^0.1.12", "@datafe-open/markdown-chart-react": "^0.1.12", "@tanstack/react-virtual": "^3.13.26", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.0", diff --git a/packages/cli/src/serve/acp-http/index.ts b/packages/cli/src/serve/acp-http/index.ts index 172e6f781c3..1db2a6ab7e5 100644 --- a/packages/cli/src/serve/acp-http/index.ts +++ b/packages/cli/src/serve/acp-http/index.ts @@ -493,6 +493,8 @@ export interface MountAcpHttpOptions { */ export interface ExtraWsRoute { path: string; + /** The route resolves its own workspace lifecycle instead of using primary. */ + bypassPrimaryDrain?: boolean; onConnection: (ws: WebSocket, req: IncomingMessage) => void; } @@ -1844,7 +1846,7 @@ export function mountAcpHttp( activeMount = resolvedMount; } - if (activeMount.draining) { + if (extraRoute?.bypassPrimaryDrain !== true && activeMount.draining) { logReject(`workspace-draining ${activeMount.routeLabel}`); socket.write( 'HTTP/1.1 503 Service Unavailable\r\nRetry-After: 5\r\n\r\n', diff --git a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts index a1bafd36d89..af828491b94 100644 --- a/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts +++ b/packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts @@ -293,6 +293,17 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { HTTPS_PROXY: 'http://primary-proxy.example:8080', }, workspaceRegistry, + extraWsRoutes: [ + { + path: '/test-extra', + bypassPrimaryDrain: true, + onConnection: (ws) => { + ws.send('extra-ready'); + ws.close(1000, 'done'); + }, + }, + { path: '/test-primary-extra', onConnection: () => {} }, + ], deviceFlowRegistry, cdpTunnelOverWs: true, cdpTunnelRegistry: cdpRegistry, @@ -1745,6 +1756,41 @@ describe('workspace-qualified ACP (/workspaces/:workspace/acp)', () => { expect(response).toEqual({ status: 503, retryAfter: '5' }); }); + it('does not apply primary ACP drain to an extra WebSocket route', async () => { + handle!.beginWorkspaceDrain('primary-id'); + + const message = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/test-extra`, { + handshakeTimeout: 2000, + }); + ws.on('message', (data: WebSocket.RawData) => resolve(data.toString())); + ws.on('error', reject); + }); + + expect(message).toBe('extra-ready'); + }); + + it('keeps primary-scoped extra routes behind the primary drain gate', async () => { + handle!.beginWorkspaceDrain('primary-id'); + + const status = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/test-primary-extra`, { + handshakeTimeout: 2000, + }); + ws.on('unexpected-response', (_req, res) => { + resolve(res.statusCode); + ws.terminate(); + }); + ws.on('open', () => { + ws.close(); + reject(new Error('primary-scoped extra route should not open')); + }); + ws.on('error', reject); + }); + + expect(status).toBe(503); + }); + it('rejects unowned and spoofed correlation frames during drain', async () => { const replies = await new Promise>>( (resolve, reject) => { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 46044d94f72..77c6fa5359e 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -445,6 +445,7 @@ export const SERVE_CAPABILITY_REGISTRY = { // gate. `/live/status` remains the dynamic readiness surface for the Host, // permissions, self-checks, and provider reachability. realtime_voice: { since: 'v1' }, + web_terminal: { since: 'v1' }, } as const satisfies Record; export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY; diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts new file mode 100644 index 00000000000..be32c807c10 --- /dev/null +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -0,0 +1,481 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import type { IncomingMessage } from 'node:http'; +import type { WebTerminalRegistry } from '@qwen-code/qwen-code-core'; +import type { WebSocket } from 'ws'; +import { describe, expect, it, vi } from 'vitest'; +import { createTerminalWsHandler } from './terminal.js'; + +const context = { + workspaceCwd: '/workspace', + env: { PATH: '/runtime/bin' }, +}; +const resolveWorkspace = (selector: string) => + selector === '/workspace' ? context : undefined; +const request = { + url: '/terminal?terminalId=terminal%3Amanual-1&cwd=%2Fworkspace', +} as IncomingMessage; + +class FakeWebSocket extends EventEmitter { + readonly OPEN = 1; + readonly readyState = this.OPEN; + readonly sent: unknown[] = []; + readonly send = vi.fn((data: unknown) => this.sent.push(data)); + readonly close = vi.fn(); +} + +function sentOutput(ws: FakeWebSocket): string[] { + return ws.sent + .filter((data): data is Buffer => Buffer.isBuffer(data)) + .map((data) => data.toString()); +} + +function registryWithSnapshot( + snapshot: + | { + output: string; + exited: boolean; + exitCode?: number; + workspaceCwd: string; + } + | undefined, +) { + return { + create: vi.fn(async () => ({ terminalId: 'terminal:manual-1' })), + readSnapshot: vi.fn(() => snapshot), + addOutputListener: vi.fn(() => vi.fn()), + addExitListener: vi.fn(() => vi.fn()), + resize: vi.fn(), + write: vi.fn(), + release: vi.fn(), + releaseWorkspace: vi.fn(), + } as unknown as WebTerminalRegistry; +} + +describe('terminal WebSocket route', () => { + it('rejects invalid workspaces and terminal ids before creating a PTY', async () => { + const registry = registryWithSnapshot(undefined); + const unknown = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + unknown as unknown as WebSocket, + { + url: '/terminal?terminalId=terminal%3Amanual-1&cwd=%2Funknown', + } as IncomingMessage, + ); + expect(unknown.close).toHaveBeenCalledWith( + 4002, + 'Terminal workspace unavailable', + ); + + const oversizedId = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + oversizedId as unknown as WebSocket, + { + url: `/terminal?terminalId=${'x'.repeat(257)}&cwd=%2Fworkspace`, + } as IncomingMessage, + ); + expect(oversizedId.close).toHaveBeenCalledWith( + 4002, + 'Terminal workspace unavailable', + ); + expect(registry.create).not.toHaveBeenCalled(); + }); + + it('creates with the resolved runtime context and flushes buffered input', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + vi.mocked(registry.readSnapshot) + .mockReturnValueOnce(undefined) + .mockReturnValue({ + output: 'prompt $ ', + exited: false, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + ws.emit('message', 'echo ready\r'); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(registry.create).toHaveBeenCalledWith({ + terminalId: 'terminal:manual-1', + workspaceCwd: '/workspace', + env: { PATH: '/runtime/bin' }, + }); + expect(sentOutput(ws)).toContain('prompt $ '); + expect(registry.write).toHaveBeenCalledWith( + 'terminal:manual-1', + 'echo ready\r', + ); + }); + + it('replays the exit state and closes instead of swallowing input', async () => { + const registry = registryWithSnapshot({ + output: 'done\r\n', + exited: true, + exitCode: 7, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + expect(sentOutput(ws)).toEqual(['done\r\n']); + expect(ws.sent).toContain('\x00{"type":"exit","exitCode":7}'); + expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + expect(registry.write).not.toHaveBeenCalled(); + }); + + it('closes cleanly when the session disappears before replay', async () => { + const snapshot = { + output: '', + exited: false, + workspaceCwd: '/workspace', + }; + const registry = registryWithSnapshot(snapshot); + const detachOutput = vi.fn(); + vi.mocked(registry.readSnapshot) + .mockReturnValueOnce(snapshot) + .mockReturnValueOnce(undefined); + vi.mocked(registry.addOutputListener).mockReturnValueOnce(detachOutput); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + expect(detachOutput).toHaveBeenCalledOnce(); + expect(ws.sent).toContain( + '\x00{"type":"error","message":"Session unavailable"}', + ); + expect(ws.close).toHaveBeenCalledWith(4002, 'Session unavailable'); + }); + + it('forwards a live PTY exit and closes the WebSocket', async () => { + let exit: ((event: { exitCode: number }) => void) | undefined; + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addExitListener).mockImplementationOnce( + (_terminalId, listener) => { + exit = listener; + return vi.fn(); + }, + ); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + exit?.({ exitCode: 9 }); + + expect(ws.sent).toContain('\x00{"type":"exit","exitCode":9}'); + expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + }); + + it('closes an active WebSocket when its workspace is released', async () => { + let exit: ((event: { exitCode: number }) => void) | undefined; + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addExitListener).mockImplementationOnce( + (_terminalId, listener) => { + exit = listener; + return vi.fn(); + }, + ); + vi.mocked(registry.releaseWorkspace).mockImplementationOnce(() => { + exit?.({ exitCode: 143 }); + }); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + registry.releaseWorkspace('/workspace'); + + expect(ws.sent).toContain('\x00{"type":"exit","exitCode":143}'); + expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + }); + + it('sends live PTY output as binary and allows reconnect overlap', async () => { + const listeners: Array<(data: string) => void> = []; + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addOutputListener).mockImplementation( + (_terminalId, listener) => { + listeners.push(listener); + return vi.fn(); + }, + ); + const first = new FakeWebSocket(); + const second = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + first as unknown as WebSocket, + request, + ); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + second as unknown as WebSocket, + request, + ); + listeners[0]?.('live'); + listeners[1]?.('live'); + + expect(sentOutput(first)).toContain('live'); + expect(sentOutput(second)).toContain('live'); + expect(registry.create).not.toHaveBeenCalled(); + }); + + it('keeps a session created after its socket closed for reconnect', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + + ws.emit('close'); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(registry.release).not.toHaveBeenCalled(); + expect(registry.addOutputListener).not.toHaveBeenCalled(); + }); + + it('releases a session explicitly closed while creation is pending', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + + ws.emit('message', '\x00{"type":"release"}'); + ws.emit('close'); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(ws.close).toHaveBeenCalledWith(4004, 'Terminal released'); + expect(registry.addOutputListener).not.toHaveBeenCalled(); + }); + + it('retries while another connection is creating the terminal', async () => { + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockResolvedValueOnce({ + error: 'Web terminal terminal:manual-1 is being created', + retryable: true, + }); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal is being created'); + expect(ws.sent).toEqual([]); + }); + + it('uses a bounded close reason for a long terminal id', async () => { + const terminalId = 'x'.repeat(256); + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockResolvedValueOnce({ + error: `Web terminal ${terminalId} is being created`, + retryable: true, + }); + const ws = new FakeWebSocket(); + ws.close.mockImplementation((_code, reason) => { + if (Buffer.byteLength(String(reason)) > 123) { + throw new RangeError('close reason too large'); + } + }); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + { + url: `/terminal?terminalId=${terminalId}&cwd=%2Fworkspace`, + } as IncomingMessage, + ); + + expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal is being created'); + }); + + it('caps input buffered while PTY creation is pending', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + + ws.emit('message', 'x'.repeat(64 * 1024 + 1)); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(ws.close).toHaveBeenCalledWith(4001, 'Terminal input too large'); + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(registry.addOutputListener).not.toHaveBeenCalled(); + }); + + it('prevents a terminal id from being claimed by another workspace', async () => { + const registry = registryWithSnapshot({ + output: 'secret', + exited: false, + workspaceCwd: '/other', + }); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + expect(ws.close).toHaveBeenCalledWith(4002, 'Terminal workspace mismatch'); + expect(registry.addOutputListener).not.toHaveBeenCalled(); + }); + + it('handles release and resize controls while dropping invalid text controls', async () => { + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + ws.emit('message', '\x00not-json'); + ws.emit('message', Buffer.from('\x00{"type":"release"}'), true); + ws.emit('message', '\x00{"type":"resize","cols":1001,"rows":40}'); + ws.emit('message', '\x00{"type":"resize","cols":120,"rows":40}'); + ws.emit('message', '\x00{"type":"release"}'); + + expect(registry.write).not.toHaveBeenCalledWith( + 'terminal:manual-1', + '\x00not-json', + ); + expect(registry.write).not.toHaveBeenCalledWith( + 'terminal:manual-1', + '\x00{"type":"resize","cols":1001,"rows":40}', + ); + expect(registry.write).toHaveBeenCalledWith( + 'terminal:manual-1', + '\x00{"type":"release"}', + ); + expect(registry.resize).toHaveBeenCalledWith('terminal:manual-1', 120, 40); + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(ws.close).toHaveBeenCalledWith(4004, 'Terminal released'); + }); + + it.each([ + { name: 'input', data: Buffer.from('pwd\r'), isBinary: true }, + { + name: 'resize', + data: '\x00{"type":"resize","cols":80,"rows":24}', + isBinary: false, + }, + ])( + 'reports an error when the workspace becomes unavailable during $name', + async ({ data, isBinary }) => { + let available = true; + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, () => + available ? context : undefined, + ).onConnection(ws as unknown as WebSocket, request); + + available = false; + ws.emit('message', data, isBinary); + + expect(ws.sent).toContain( + '\x00{"type":"error","message":"Terminal workspace unavailable"}', + ); + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(ws.close).toHaveBeenCalledWith( + 4002, + 'Terminal workspace unavailable', + ); + expect(registry.write).not.toHaveBeenCalled(); + expect(registry.resize).not.toHaveBeenCalled(); + }, + ); + + it('rejects create failures with a non-retryable close code', async () => { + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockResolvedValueOnce({ + error: 'PTY unavailable', + }); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + expect(ws.close).toHaveBeenCalledWith(4001, 'Terminal unavailable'); + }); +}); diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts new file mode 100644 index 00000000000..3f3af5d1c84 --- /dev/null +++ b/packages/cli/src/serve/routes/terminal.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IncomingMessage } from 'node:http'; +import type { WebTerminalRegistry } from '@qwen-code/qwen-code-core'; +import type { WebSocket } from 'ws'; +import type { ExtraWsRoute } from '../acp-http/index.js'; + +const TERMINAL_WS_PATH = '/terminal'; +const CONTROL_FRAME_PREFIX = '\x00'; +const MAX_PENDING_INPUT_BYTES = 64 * 1024; +const MAX_TERMINAL_DIMENSION = 1000; + +export interface WebTerminalWorkspaceContext { + workspaceCwd: string; + env: Readonly; +} + +type TerminalControl = + | { type: 'resize'; cols: number; rows: number } + | { type: 'release' }; + +function sendOutput(ws: WebSocket, text: string): void { + if (ws.readyState === ws.OPEN) ws.send(Buffer.from(text)); +} + +function sendControl(ws: WebSocket, payload: unknown): void { + if (ws.readyState === ws.OPEN) { + ws.send(CONTROL_FRAME_PREFIX + JSON.stringify(payload)); + } +} + +function parseControl(data: string): TerminalControl | null { + if (!data.startsWith(CONTROL_FRAME_PREFIX)) return null; + try { + const parsed = JSON.parse(data.slice(CONTROL_FRAME_PREFIX.length)) as { + type?: unknown; + cols?: unknown; + rows?: unknown; + }; + if (parsed.type === 'release') return { type: 'release' }; + if ( + parsed.type === 'resize' && + Number.isInteger(parsed.cols) && + Number.isInteger(parsed.rows) && + (parsed.cols as number) > 0 && + (parsed.rows as number) > 0 && + (parsed.cols as number) <= MAX_TERMINAL_DIMENSION && + (parsed.rows as number) <= MAX_TERMINAL_DIMENSION + ) { + return { + type: 'resize', + cols: parsed.cols as number, + rows: parsed.rows as number, + }; + } + } catch { + // Not a valid control frame; preserve it as terminal input. + } + return null; +} + +function toText(data: unknown): string { + return typeof data === 'string' + ? data + : Buffer.isBuffer(data) + ? data.toString('utf8') + : Array.isArray(data) + ? Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString( + 'utf8', + ) + : ''; +} + +export function createTerminalWsHandler( + registry: WebTerminalRegistry, + resolveWorkspace: ( + selector: string, + ) => WebTerminalWorkspaceContext | undefined, +): ExtraWsRoute { + return { + path: TERMINAL_WS_PATH, + bypassPrimaryDrain: true, + onConnection: async (ws: WebSocket, req: IncomingMessage) => { + const url = new URL(req.url ?? '', 'http://localhost'); + const terminalId = url.searchParams.get('terminalId'); + const selector = url.searchParams.get('cwd'); + const workspace = selector ? resolveWorkspace(selector) : undefined; + if (!terminalId || terminalId.length > 256 || !selector || !workspace) { + sendControl(ws, { + type: 'error', + message: 'Terminal workspace unavailable', + }); + ws.close(4002, 'Terminal workspace unavailable'); + return; + } + const workspaceSelector = selector; + + let closed = false; + let created = false; + let inputRejected = false; + let releaseRequested = false; + let pendingBytes = 0; + const pending: Array<{ text: string; isBinary: boolean }> = []; + const markClosed = () => { + closed = true; + }; + const bufferMessage = (data: unknown, isBinary = false) => { + const text = toText(data); + if (!isBinary && parseControl(text)?.type === 'release') { + releaseRequested = true; + } + pendingBytes += Buffer.byteLength(text); + if (pendingBytes > MAX_PENDING_INPUT_BYTES) { + inputRejected = true; + sendControl(ws, { + type: 'error', + message: 'Terminal input too large', + }); + ws.close(4001, 'Terminal input too large'); + return; + } + pending.push({ text, isBinary }); + }; + ws.once('close', markClosed); + ws.on('message', bufferMessage); + + let snapshot = registry.readSnapshot(terminalId); + if (snapshot && snapshot.workspaceCwd !== workspace.workspaceCwd) { + sendControl(ws, { + type: 'error', + message: 'Terminal workspace mismatch', + }); + ws.close(4002, 'Terminal workspace mismatch'); + return; + } + + if (!snapshot) { + let result; + try { + result = await registry.create({ + terminalId, + workspaceCwd: workspace.workspaceCwd, + env: workspace.env, + }); + } catch { + result = { error: 'Failed to create terminal' } as const; + } + if ('error' in result) { + if (releaseRequested) { + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4004, 'Terminal released'); + return; + } + if (result.retryable) { + ws.close(1013, 'Terminal is being created'); + return; + } + sendControl(ws, { type: 'error', message: result.error }); + ws.close(4001, 'Terminal unavailable'); + return; + } + created = true; + } + + if (releaseRequested) { + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4004, 'Terminal released'); + return; + } + if (closed || inputRejected) { + if (created && inputRejected) { + registry.release(terminalId, workspace.workspaceCwd); + } + return; + } + + let cleaned = false; + const detach: { output?: () => void; exit?: () => void } = {}; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + detach.output?.(); + detach.exit?.(); + ws.off('message', onMessage); + }; + const finishExited = (exitCode: number | undefined) => { + sendControl(ws, { type: 'exit', exitCode }); + cleanup(); + ws.close(4000, 'Terminal exited'); + }; + const onMessage = (data: unknown, isBinary = false) => { + const text = toText(data); + const control = isBinary ? null : parseControl(text); + if (!isBinary && text.startsWith(CONTROL_FRAME_PREFIX) && !control) { + return; + } + if (control?.type === 'release') { + cleanup(); + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4004, 'Terminal released'); + return; + } + if (control?.type === 'resize') { + const currentWorkspace = resolveWorkspace(workspaceSelector); + if (currentWorkspace?.workspaceCwd !== workspace.workspaceCwd) { + sendControl(ws, { + type: 'error', + message: 'Terminal workspace unavailable', + }); + cleanup(); + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4002, 'Terminal workspace unavailable'); + return; + } + registry.resize(terminalId, control.cols, control.rows); + return; + } + const currentWorkspace = resolveWorkspace(workspaceSelector); + if (currentWorkspace?.workspaceCwd !== workspace.workspaceCwd) { + sendControl(ws, { + type: 'error', + message: 'Terminal workspace unavailable', + }); + cleanup(); + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4002, 'Terminal workspace unavailable'); + return; + } + registry.write(terminalId, text); + }; + + detach.output = registry.addOutputListener(terminalId, (data) => + sendOutput(ws, data), + ); + if (!detach.output) { + sendControl(ws, { type: 'error', message: 'Session unavailable' }); + ws.close(4002, 'Session unavailable'); + return; + } + detach.exit = registry.addExitListener(terminalId, (event) => + finishExited(event.exitCode), + ); + snapshot = registry.readSnapshot(terminalId); + if (!snapshot || snapshot.workspaceCwd !== workspace.workspaceCwd) { + cleanup(); + sendControl(ws, { type: 'error', message: 'Session unavailable' }); + ws.close(4002, 'Session unavailable'); + return; + } + + ws.off('message', bufferMessage); + ws.on('message', onMessage); + ws.on('close', cleanup); + ws.on('error', cleanup); + sendOutput(ws, snapshot.output); + if (snapshot.exited) { + finishExited(snapshot.exitCode); + return; + } + for (const message of pending) { + onMessage(message.text, message.isBinary); + } + }, + }; +} + +export { TERMINAL_WS_PATH }; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index d72c1bd88a1..217955b1024 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -3447,6 +3447,7 @@ async function runQwenServeImpl( stopScheduledTaskKeepalive?: () => void; stopWorkspaceGitState?: () => void; stopLiveCoordinator?: () => void; + stopWebTerminalRegistry?: () => void; subSessionStoppers?: Array<() => void>; }; const stopSafely = (name: string, stop: (() => void) | undefined) => { @@ -3463,6 +3464,7 @@ async function runQwenServeImpl( stopSafely('scheduled-task keepalive', locals.stopScheduledTaskKeepalive); stopSafely('workspace git state', locals.stopWorkspaceGitState); stopSafely('Live Host coordinator', locals.stopLiveCoordinator); + stopSafely('web terminal registry', locals.stopWebTerminalRegistry); stopTrustPolicyMonitor(app); for (const stop of locals.subSessionStoppers ?? []) { stopSafely('sub-session launcher', stop); @@ -5660,6 +5662,9 @@ async function runQwenServeImpl( const stopScheduledTaskKeepaliveForWorkspace = app?.locals?.[ 'stopScheduledTaskKeepaliveForWorkspace' ] as ((workspaceCwd: string) => void) | undefined; + const releaseWebTerminalsForWorkspace = app?.locals?.[ + 'releaseWebTerminalsForWorkspace' + ] as ((workspaceCwd: string) => void) | undefined; try { stopScheduledTaskKeepaliveForWorkspace?.(runtimeToDrain.workspaceCwd); } catch (err) { @@ -5668,6 +5673,14 @@ async function runQwenServeImpl( err instanceof Error ? err : null, ); } + try { + releaseWebTerminalsForWorkspace?.(runtimeToDrain.workspaceCwd); + } catch (err) { + daemonLog.error( + 'workspace web-terminal drain error', + err instanceof Error ? err : null, + ); + } }, cancelDrain(runtimeToDrain: WorkspaceRuntime): void { if (runtimeToDrain.primary && bridgeRef === undefined) { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 52b5aed1833..479934b5b49 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -782,6 +782,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'browser_automation_mcp', 'voice_transcribe', 'realtime_voice', + 'web_terminal', ] as const; interface FakeBridgeOpts { @@ -2791,6 +2792,12 @@ describe('createServeApp', () => { ).toContain('voice_transcribe'); }); + it('advertises `web_terminal` for frontend version compatibility', () => { + expect(getAdvertisedServeFeatures(undefined, {})).toContain( + 'web_terminal', + ); + }); + it('honors every entry in CONDITIONAL_SERVE_FEATURES (PR #4236 review #3254467192 — drift insurance)', () => { // Iterate the Map so any future conditional tag added here whose // predicate isn't honored by `getAdvertisedServeFeatures` fails diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index a9bfb81b6ba..720c956ac52 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -11,6 +11,7 @@ import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; import { hashDaemonWorkspace, Storage, + WebTerminalRegistry, type DurableCronTask, } from '@qwen-code/qwen-code-core'; import type { DaemonLogger } from './daemon-logger.js'; @@ -58,6 +59,7 @@ import { type ExtraWsRoute, } from './acp-http/index.js'; import { createVoiceWsConnectionHandler } from './voice/voice-ws.js'; +import { createTerminalWsHandler } from './routes/terminal.js'; import { ClientMcpSenderRegistry, createClientMcpServerProvider, @@ -871,6 +873,15 @@ export function createServeApp( const primaryEffectiveEnv = getRuntimeEffectiveEnv(primaryRuntimeEnvMetadata); const daemonEnv = deps.daemonEnv ?? process.env; const daemonEnvAtBoot = Object.freeze({ ...daemonEnv }); + const webTerminalRegistry = new WebTerminalRegistry(); + const webTerminalLocals = app.locals as { + stopWebTerminalRegistry?: () => void; + releaseWebTerminalsForWorkspace?: (workspaceCwd: string) => void; + }; + webTerminalLocals.stopWebTerminalRegistry = () => + webTerminalRegistry.dispose(); + webTerminalLocals.releaseWebTerminalsForWorkspace = (workspaceCwd) => + webTerminalRegistry.releaseWorkspace(workspaceCwd); const acpHttpEnabledAtBoot = resolveAcpHttpEnabled(daemonEnvAtBoot); const runtimePlatform = deps.runtimePlatform ?? process.platform; const liveVoiceSurfaceAvailable = @@ -2906,6 +2917,22 @@ export function createServeApp( acquireVoiceLease: acquirePrimaryVoiceLease, }), }, + createTerminalWsHandler(webTerminalRegistry, (selector) => { + const runtime = resolveRegisteredWorkspaceRuntimeByPathSelector( + workspaceRegistry, + selector, + ); + if ( + !runtime || + (runtime.primary ? !isPrimaryWorkspaceTrusted() : !runtime.trusted) + ) { + return undefined; + } + return { + workspaceCwd: runtime.workspaceCwd, + env: getRuntimeEffectiveEnv(runtime.env) ?? daemonEnvAtBoot, + }; + }), ], workspaceVoiceConnection: (runtime, ws, req) => createVoiceWsConnectionHandler(runtime.workspaceCwd, { @@ -2996,6 +3023,7 @@ export function createServeApp( stopAppResource(() => deviceFlowRegistry.dispose()); stopAppResource(() => rateLimiter?.setDraining(true)); stopAppResource(() => rateLimiter?.dispose()); + stopAppResource(() => webTerminalRegistry.dispose()); const drains = await Promise.allSettled(pendingDrains); stopAppResource(() => acpHandleRef.current?.dispose()); const bridgeDrains = await Promise.allSettled( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bedbc92d344..5652026b215 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -390,6 +390,7 @@ export { escapeXml } from './utils/xml.js'; export * from './services/shellExecutionService.js'; export * from './services/monitorRegistry.js'; export * from './services/backgroundShellRegistry.js'; +export * from './services/web-terminal-registry.js'; export * from './agents/workflow-run-registry.js'; export * from './agents/workflow-snapshot.js'; export { diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts new file mode 100644 index 00000000000..9c7d0687be3 --- /dev/null +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { spawn, getPty } = vi.hoisted(() => ({ + spawn: vi.fn(), + getPty: vi.fn(), +})); + +vi.mock('../utils/getPty.js', () => ({ getPty })); + +import { + MAX_CONCURRENT_WEB_TERMINALS, + resolveWebTerminalShell, + WebTerminalRegistry, +} from './web-terminal-registry.js'; + +describe('WebTerminalRegistry', () => { + let onData: (data: string) => void; + let onExit: (event: { exitCode: number; signal?: number }) => void; + let write: ReturnType; + let resize: ReturnType; + let kill: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + write = vi.fn(); + resize = vi.fn(); + kill = vi.fn(); + spawn.mockReturnValue({ + pid: 1, + write, + resize, + kill, + onData: vi.fn((listener) => { + onData = listener; + }), + onExit: vi.fn((listener) => { + onExit = listener; + }), + }); + getPty.mockResolvedValue({ module: { spawn }, name: 'node-pty' }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses the resolved workspace and normalizes its environment', async () => { + const registry = new WebTerminalRegistry(); + + await registry.create({ + workspaceCwd: '/workspace', + env: { + PATH: '/bin', + TERM: 'dumb', + NO_COLOR: '1', + FORCE_COLOR: '0', + npm_config_prefix: '/usr/local', + }, + }); + + expect(spawn).toHaveBeenCalledWith( + expect.any(String), + [], + expect.objectContaining({ + cwd: '/workspace', + env: { + PATH: '/bin', + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + CLICOLOR: '1', + PROMPT_EOL_MARK: '', + }, + }), + ); + }); + + it('selects a native Windows shell', () => { + expect(resolveWebTerminalShell('win32', { COMSPEC: 'pwsh.exe' })).toEqual({ + file: 'pwsh.exe', + args: [], + }); + expect(resolveWebTerminalShell('linux', {})).toEqual({ + file: '/bin/sh', + args: [], + }); + }); + + it('marks concurrent creation as retryable while rejecting established duplicates', async () => { + let resolvePty: ((value: unknown) => void) | undefined; + getPty.mockReturnValueOnce( + new Promise((resolve) => { + resolvePty = resolve; + }), + ); + const registry = new WebTerminalRegistry(); + const first = registry.create({ + terminalId: 'terminal:manual-1', + workspaceCwd: '/workspace', + }); + + await expect( + registry.create({ + terminalId: 'terminal:manual-1', + workspaceCwd: '/workspace', + }), + ).resolves.toEqual({ + error: 'Web terminal terminal:manual-1 is being created', + retryable: true, + }); + resolvePty?.({ module: { spawn }, name: 'node-pty' }); + await first; + + await expect( + registry.create({ + terminalId: 'terminal:manual-1', + workspaceCwd: '/workspace', + }), + ).resolves.toEqual({ + error: 'Web terminal terminal:manual-1 already exists', + }); + }); + + it('caps established and in-flight terminal sessions', async () => { + const registry = new WebTerminalRegistry(); + for (let i = 0; i < MAX_CONCURRENT_WEB_TERMINALS - 1; i++) { + await registry.create({ + terminalId: `terminal:${i}`, + workspaceCwd: '/workspace', + }); + } + let resolvePty: ((value: unknown) => void) | undefined; + getPty.mockReturnValueOnce( + new Promise((resolve) => { + resolvePty = resolve; + }), + ); + const pending = registry.create({ + terminalId: 'terminal:pending', + workspaceCwd: '/workspace', + }); + + await expect( + registry.create({ + terminalId: 'terminal:over-limit', + workspaceCwd: '/workspace', + }), + ).resolves.toEqual({ error: 'Web terminal limit reached' }); + resolvePty?.({ module: { spawn }, name: 'node-pty' }); + await pending; + + registry.release('terminal:0'); + await expect( + registry.create({ + terminalId: 'terminal:replacement', + workspaceCwd: '/workspace', + }), + ).resolves.toEqual({ terminalId: 'terminal:replacement' }); + }); + + it('returns stable errors when PTY loading or spawning fails', async () => { + const registry = new WebTerminalRegistry(); + getPty.mockResolvedValueOnce(null); + await expect( + registry.create({ workspaceCwd: '/workspace' }), + ).resolves.toEqual({ error: 'PTY not available' }); + + spawn.mockImplementationOnce(() => { + throw new Error('spawn failed'); + }); + await expect( + registry.create({ workspaceCwd: '/workspace' }), + ).resolves.toEqual({ error: 'Failed to spawn shell' }); + }); + + it('caps replay output and records exit state', async () => { + const registry = new WebTerminalRegistry(); + const created = await registry.create({ + terminalId: 'terminal:buffer', + workspaceCwd: '/workspace', + }); + if ('error' in created) throw new Error(created.error); + const { terminalId } = created; + + for (let i = 0; i < 4001; i++) onData(String(i % 10)); + onExit({ exitCode: 7 }); + + const snapshot = registry.readSnapshot(terminalId); + expect(snapshot?.output).toHaveLength(4000); + expect(snapshot).toMatchObject({ + exited: true, + exitCode: 7, + workspaceCwd: '/workspace', + }); + expect(registry.write(terminalId, 'ignored')).toBe(false); + expect(registry.resize(terminalId, 80, 24)).toBe(false); + }); + + it('caps replay output by UTF-8 bytes', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:utf8-buffer', + workspaceCwd: '/workspace', + }); + + onData('界'.repeat(1_500_000)); + + expect(registry.readSnapshot('terminal:utf8-buffer')?.output).toBe(''); + }); + + it('releases a live PTY immediately and only once', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:release', + workspaceCwd: '/workspace', + }); + + expect(registry.release('terminal:release')).toBe(true); + expect(registry.release('terminal:release')).toBe(false); + expect(kill).toHaveBeenCalledOnce(); + expect(registry.readSnapshot('terminal:release')).toBeUndefined(); + }); + + it('does not spawn a PTY after disposal wins an in-flight create', async () => { + let resolvePty: ((value: unknown) => void) | undefined; + getPty.mockReturnValueOnce( + new Promise((resolve) => { + resolvePty = resolve; + }), + ); + const registry = new WebTerminalRegistry(); + const creating = registry.create({ + terminalId: 'terminal:pending', + workspaceCwd: '/workspace', + }); + + registry.dispose(); + resolvePty?.({ module: { spawn }, name: 'node-pty' }); + + await expect(creating).resolves.toEqual({ + error: 'Web terminal registry disposed', + }); + expect(spawn).not.toHaveBeenCalled(); + }); + + it.each(['terminal', 'workspace'] as const)( + 'cancels in-flight creation when releasing its %s', + async (scope) => { + let resolvePty: ((value: unknown) => void) | undefined; + getPty.mockReturnValueOnce( + new Promise((resolve) => { + resolvePty = resolve; + }), + ); + const registry = new WebTerminalRegistry(); + const creating = registry.create({ + terminalId: 'terminal:pending', + workspaceCwd: '/workspace', + }); + + if (scope === 'terminal') { + expect(registry.release('terminal:pending')).toBe(true); + } else { + registry.releaseWorkspace('/workspace'); + } + resolvePty?.({ module: { spawn }, name: 'node-pty' }); + + await expect(creating).resolves.toEqual({ + error: 'Web terminal creation cancelled', + }); + expect(spawn).not.toHaveBeenCalled(); + }, + ); + + it('keeps another workspace in-flight when one workspace is released', async () => { + let resolveA: ((value: unknown) => void) | undefined; + let resolveB: ((value: unknown) => void) | undefined; + getPty + .mockReturnValueOnce( + new Promise((resolve) => { + resolveA = resolve; + }), + ) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveB = resolve; + }), + ); + const registry = new WebTerminalRegistry(); + const creatingA = registry.create({ + terminalId: 'terminal:a', + workspaceCwd: '/workspace-a', + }); + const creatingB = registry.create({ + terminalId: 'terminal:b', + workspaceCwd: '/workspace-b', + }); + + registry.releaseWorkspace('/workspace-a'); + resolveA?.({ module: { spawn }, name: 'node-pty' }); + resolveB?.({ module: { spawn }, name: 'node-pty' }); + + await expect(creatingA).resolves.toEqual({ + error: 'Web terminal creation cancelled', + }); + await expect(creatingB).resolves.toEqual({ terminalId: 'terminal:b' }); + expect(spawn).toHaveBeenCalledOnce(); + expect(spawn).toHaveBeenCalledWith( + expect.any(String), + [], + expect.objectContaining({ cwd: '/workspace-b' }), + ); + }); + + it('does not let another workspace cancel an in-flight terminal', async () => { + let resolvePty: ((value: unknown) => void) | undefined; + getPty.mockReturnValueOnce( + new Promise((resolve) => { + resolvePty = resolve; + }), + ); + const registry = new WebTerminalRegistry(); + const creating = registry.create({ + terminalId: 'terminal:pending', + workspaceCwd: '/workspace-a', + }); + + expect(registry.release('terminal:pending', '/workspace-b')).toBe(false); + resolvePty?.({ module: { spawn }, name: 'node-pty' }); + + await expect(creating).resolves.toEqual({ + terminalId: 'terminal:pending', + }); + expect(spawn).toHaveBeenCalledOnce(); + }); + + it('releases only terminals owned by a draining workspace', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:a', + workspaceCwd: '/workspace-a', + }); + const killA = kill; + const exitA = onExit; + const exitListener = vi.fn(); + registry.addExitListener('terminal:a', exitListener); + killA.mockImplementation(() => exitA({ exitCode: 0 })); + await registry.create({ + terminalId: 'terminal:b', + workspaceCwd: '/workspace-b', + }); + + registry.releaseWorkspace('/workspace-a'); + + expect(killA).toHaveBeenCalledOnce(); + expect(exitListener).toHaveBeenCalledWith({ exitCode: 0 }); + expect(registry.readSnapshot('terminal:a')).toBeUndefined(); + expect(registry.readSnapshot('terminal:b')).toBeDefined(); + }); + + it('reclaims only after the final listener stays detached', async () => { + vi.useFakeTimers(); + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:idle', + workspaceCwd: '/workspace', + }); + const detachOne = registry.addOutputListener('terminal:idle', vi.fn()); + const detachTwo = registry.addOutputListener('terminal:idle', vi.fn()); + + detachOne?.(); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + expect(kill).not.toHaveBeenCalled(); + + detachTwo?.(); + await vi.advanceTimersByTimeAsync(14 * 60 * 1000); + const detachReconnect = registry.addOutputListener( + 'terminal:idle', + vi.fn(), + ); + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + expect(kill).not.toHaveBeenCalled(); + + detachReconnect?.(); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + expect(kill).toHaveBeenCalledOnce(); + expect(registry.readSnapshot('terminal:idle')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts new file mode 100644 index 00000000000..f1712e2c005 --- /dev/null +++ b/packages/core/src/services/web-terminal-registry.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getPty } from '../utils/getPty.js'; + +/** + * Minimal PTY surface used by the web terminal registry. Backed by node-pty. + */ +export interface WebTerminalPty { + readonly pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; +} + +export interface WebTerminalSnapshot { + output: string; + exited: boolean; + exitCode?: number; + workspaceCwd: string; +} + +export interface CreateWebTerminalOptions { + terminalId?: string; + workspaceCwd: string; + env?: Readonly; + cols?: number; + rows?: number; +} + +export interface CreateWebTerminalResult { + terminalId: string; +} + +/** Upper bound on replayed scrollback per PTY session (roughly 4 MB). */ +const MAX_BUFFER_CHUNKS = 4000; +const MAX_BUFFER_BYTES = 4 * 1024 * 1024; +export const MAX_CONCURRENT_WEB_TERMINALS = 8; +/** Reclaim a PTY session after this long with no connected listener. */ +const IDLE_RECLAIM_MS = 15 * 60 * 1000; + +interface PtySession { + pty: WebTerminalPty; + workspaceCwd: string; + buffer: string[]; + bufferBytes: number; + exited: boolean; + exitCode?: number; + outputListeners: Set<(data: string) => void>; + exitListeners: Set<(e: { exitCode: number; signal?: number }) => void>; + reclaimTimer?: ReturnType; +} + +interface SpawnedWebTerminalPty extends WebTerminalPty { + onData(callback: (data: string) => void): void; + onExit(callback: (e: { exitCode: number; signal?: number }) => void): void; +} + +export function resolveWebTerminalShell( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): { file: string; args: string[] } { + return platform === 'win32' + ? { file: env['COMSPEC'] ?? 'cmd.exe', args: [] } + : { file: env['SHELL'] ?? '/bin/sh', args: [] }; +} + +/** + * Long-lived interactive PTY sessions keyed by `terminalId` for the browser + * terminal WebSocket route. + */ +export class WebTerminalRegistry { + private readonly sessions = new Map(); + private readonly creating = new Map(); + private readonly cancelledCreations = new Set(); + private disposed = false; + + private readonly nextId = (() => { + let counter = 0; + return () => `web-term-${Date.now()}-${++counter}`; + })(); + + async create( + options: CreateWebTerminalOptions, + ): Promise { + if (this.disposed) return { error: 'Web terminal registry disposed' }; + const terminalId = options.terminalId ?? this.nextId(); + if (this.sessions.has(terminalId)) { + return { error: `Web terminal ${terminalId} already exists` }; + } + if (this.creating.has(terminalId)) { + return { + error: `Web terminal ${terminalId} is being created`, + retryable: true, + }; + } + if ( + this.sessions.size + this.creating.size >= + MAX_CONCURRENT_WEB_TERMINALS + ) { + return { error: 'Web terminal limit reached' }; + } + this.creating.set(terminalId, options.workspaceCwd); + let ptyImpl; + try { + ptyImpl = await getPty(); + } catch { + this.finishCreating(terminalId); + return { error: 'PTY not available' }; + } + if (this.cancelledCreations.has(terminalId)) { + this.finishCreating(terminalId); + return { error: 'Web terminal creation cancelled' }; + } + if (this.disposed) { + this.finishCreating(terminalId); + return { error: 'Web terminal registry disposed' }; + } + if (!ptyImpl) { + this.finishCreating(terminalId); + return { error: 'PTY not available' }; + } + + const env = { ...(options.env ?? process.env) }; + const { file, args } = resolveWebTerminalShell(process.platform, env); + delete env['NO_COLOR']; + delete env['FORCE_COLOR']; + delete env['npm_config_prefix']; + let spawned: SpawnedWebTerminalPty; + let proc: WebTerminalPty; + try { + spawned = ptyImpl.module.spawn(file, args, { + name: 'xterm-256color', + cols: options.cols ?? 80, + rows: options.rows ?? 24, + cwd: options.workspaceCwd, + env: { + ...env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + CLICOLOR: '1', + PROMPT_EOL_MARK: '', + }, + }) as SpawnedWebTerminalPty; + proc = { + pid: spawned.pid, + write: (data) => spawned.write(data), + resize: (cols, rows) => spawned.resize(cols, rows), + kill: () => spawned.kill(), + }; + } catch { + this.finishCreating(terminalId); + return { error: 'Failed to spawn shell' }; + } + + const session: PtySession = { + pty: proc, + workspaceCwd: options.workspaceCwd, + buffer: [], + bufferBytes: 0, + exited: false, + outputListeners: new Set(), + exitListeners: new Set(), + }; + this.sessions.set(terminalId, session); + this.finishCreating(terminalId); + + spawned.onData((data: string) => { + session.buffer.push(data); + session.bufferBytes += Buffer.byteLength(data); + while ( + session.buffer.length > MAX_BUFFER_CHUNKS || + session.bufferBytes > MAX_BUFFER_BYTES + ) { + const dropped = session.buffer.shift(); + if (dropped !== undefined) { + session.bufferBytes -= Buffer.byteLength(dropped); + } + } + for (const listener of session.outputListeners) listener(data); + }); + + spawned.onExit((e: { exitCode: number; signal?: number }) => { + session.exited = true; + session.exitCode = e.exitCode; + for (const listener of session.exitListeners) listener(e); + }); + this.scheduleReclaim(terminalId, session); + + return { terminalId }; + } + + addOutputListener( + terminalId: string, + listener: (data: string) => void, + ): (() => void) | undefined { + const session = this.sessions.get(terminalId); + if (!session) return undefined; + session.outputListeners.add(listener); + this.clearReclaim(session); + return () => { + session.outputListeners.delete(listener); + if (session.outputListeners.size === 0) + this.scheduleReclaim(terminalId, session); + }; + } + + addExitListener( + terminalId: string, + listener: (e: { exitCode: number; signal?: number }) => void, + ): (() => void) | undefined { + const session = this.sessions.get(terminalId); + if (!session) return undefined; + session.exitListeners.add(listener); + return () => session.exitListeners.delete(listener); + } + + /** Read buffered output and exit state when a browser tab reconnects. */ + readSnapshot(terminalId: string): WebTerminalSnapshot | undefined { + const session = this.sessions.get(terminalId); + if (!session) return undefined; + return { + output: session.buffer.join(''), + exited: session.exited, + workspaceCwd: session.workspaceCwd, + ...(session.exitCode !== undefined ? { exitCode: session.exitCode } : {}), + }; + } + + write(terminalId: string, data: string): boolean { + const session = this.sessions.get(terminalId); + if (!session || session.exited) return false; + try { + session.pty.write(data); + return true; + } catch { + return false; + } + } + + resize(terminalId: string, cols: number, rows: number): boolean { + const session = this.sessions.get(terminalId); + if (!session || session.exited) return false; + try { + session.pty.resize(cols, rows); + return true; + } catch { + return false; + } + } + + dispose(): void { + this.disposed = true; + for (const terminalId of [...this.sessions.keys()]) + this.release(terminalId); + } + + release(terminalId: string, workspaceCwd?: string): boolean { + const creatingWorkspaceCwd = this.creating.get(terminalId); + if (creatingWorkspaceCwd !== undefined) { + if (workspaceCwd !== undefined && workspaceCwd !== creatingWorkspaceCwd) { + return false; + } + this.cancelledCreations.add(terminalId); + return true; + } + const session = this.sessions.get(terminalId); + if ( + !session || + (workspaceCwd !== undefined && workspaceCwd !== session.workspaceCwd) + ) { + return false; + } + this.clearReclaim(session); + this.sessions.delete(terminalId); + if (!session.exited) { + try { + session.pty.kill(); + } catch { + // Ignore. + } + } + return true; + } + + releaseWorkspace(workspaceCwd: string): void { + for (const [terminalId, creatingWorkspaceCwd] of this.creating) { + if (creatingWorkspaceCwd === workspaceCwd) { + this.release(terminalId, workspaceCwd); + } + } + for (const [terminalId, session] of this.sessions) { + if (session.workspaceCwd === workspaceCwd) { + this.release(terminalId, workspaceCwd); + } + } + } + + private finishCreating(terminalId: string): void { + this.creating.delete(terminalId); + this.cancelledCreations.delete(terminalId); + } + + private clearReclaim(session: PtySession): void { + if (session.reclaimTimer) { + clearTimeout(session.reclaimTimer); + session.reclaimTimer = undefined; + } + } + + private scheduleReclaim(terminalId: string, session: PtySession): void { + if (session.reclaimTimer) clearTimeout(session.reclaimTimer); + session.reclaimTimer = setTimeout(() => { + if (session.outputListeners.size === 0) this.release(terminalId); + }, IDLE_RECLAIM_MS); + session.reclaimTimer.unref?.(); + } +} diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index a2be45529cb..1e91cad0503 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -185,6 +185,7 @@ const { qualifiedWorkspaceProviders, qualifiedSetWorkspaceSetting, sessionCatalogController, + mockReleaseWebTerminal, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -485,6 +486,7 @@ const { renamed: vi.fn(), turnCompleted: vi.fn(), }, + mockReleaseWebTerminal: vi.fn(), }; }); @@ -1694,6 +1696,17 @@ vi.doMock('./components/messages/BtwMessage', async () => { }, }; }); +vi.doMock('./components/terminal/TerminalPanel', async () => { + const React = await import('react'); + return { + releaseWebTerminal: mockReleaseWebTerminal, + TerminalPanel: (props: { terminalId: string }) => + React.createElement('div', { + 'data-testid': 'terminal-panel', + 'data-terminal-id': props.terminalId, + }), + }; +}); mockComponent('./components/QueuedPromptDisplay', 'QueuedPromptDisplay'); const { @@ -4713,6 +4726,7 @@ beforeEach(() => { // auto-restore into the next test's App mount. sessionStorage.clear(); localStorage.removeItem('qwen-code-web-shell-chat-width'); + mockReleaseWebTerminal.mockReset(); Object.defineProperty(document, 'hidden', { configurable: true, get: () => false, @@ -8055,6 +8069,86 @@ describe('App session callbacks', () => { expect(header?.parentElement?.contains(artifactDock ?? null)).toBe(false); }); + it('shows the manual terminal action when configured and advertised', () => { + mockConnection.capabilities.features = ['web_terminal']; + const { container } = renderApp({ + rightPanel: { items: ['terminal'] }, + }); + act(() => + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(), + ); + + const actions = Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ); + expect( + actions.find((button) => button.textContent?.includes('Terminal')), + ).toBeDefined(); + + act(() => + actions + .find((button) => button.textContent?.includes('Terminal')) + ?.click(), + ); + const terminalId = container + .querySelector('[data-testid="terminal-panel"]') + ?.getAttribute('data-terminal-id'); + expect(terminalId).toBeTruthy(); + act(() => + container + .querySelector('button[aria-label="Close Terminal"]') + ?.click(), + ); + expect(mockReleaseWebTerminal).toHaveBeenCalledWith(terminalId); + }); + + it('hides the manual terminal action without the capability', () => { + const { container } = renderApp({ + rightPanel: { items: ['terminal'] }, + }); + act(() => + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(), + ); + + expect( + Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ).some((button) => button.textContent?.includes('Terminal')), + ).toBe(false); + }); + + it('hides the manual terminal action when it is not configured', () => { + mockConnection.capabilities.features = ['web_terminal']; + const { container } = renderApp(); + act(() => + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(), + ); + + expect( + Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ).some((button) => button.textContent?.includes('Terminal')), + ).toBe(false); + }); + it('opens the latest reviewable turn from the empty right panel', () => { mockWorkspace.capabilities = { workspaceCwd: '/tmp/project', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index f4043383685..0a7e05221c2 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -136,6 +136,7 @@ import { type ArtifactPanelTab, type SideTaskListItem, } from './components/artifacts/ArtifactPanel'; +import { releaseWebTerminal } from './components/terminal/TerminalPanel'; import { Drawer, DrawerContent, DrawerTitle } from './components/ui/drawer'; import type { TurnOutputFileChange, @@ -2937,6 +2938,9 @@ export function App({ Boolean(connection.sessionId && connection.workspaceCwd) && connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) === true; + const webTerminalAvailable = + rightPanelItems.includes('terminal') && + connection.capabilities?.features.includes('web_terminal') === true; const [sideTaskCatalog, setSideTaskCatalog] = useState({ items: [], loaded: false, @@ -2980,6 +2984,23 @@ export function App({ if (createSideTask()) return; pushToast('error', t('sideTask.createFailed')); }, [createSideTask, pushToast, t]); + const nextTerminalTabIdRef = useRef(0); + const openTerminalTab = useCallback(() => { + const id = `terminal:${Date.now()}:${++nextTerminalTabIdRef.current}`; + const count = artifactPanelTabsRef.current.filter( + (tab) => tab.kind === 'terminal', + ).length; + const base = t('terminal.title'); + const tab: ArtifactPanelTab = { + id, + kind: 'terminal', + title: count === 0 ? base : `${base} (${count + 1})`, + workspaceCwd: connection.workspaceCwd, + }; + setArtifactPanelTabs((tabs) => [...tabs, tab]); + setActiveArtifactPanelTabId(tab.id); + setArtifactPanelOpen(true); + }, [connection.workspaceCwd, t]); const createSideTaskSession = useCallback( async (_tabId: string, parentSessionId: string, title: string) => { const ownerCwd = connection.workspaceCwd; @@ -3791,6 +3812,12 @@ export function App({ }; }, [artifactPanelOpen, artifactPanelFullscreen]); const closeArtifactPanelTab = useCallback((tabId: string) => { + if ( + artifactPanelTabsRef.current.find((tab) => tab.id === tabId)?.kind === + 'terminal' + ) { + releaseWebTerminal(tabId); + } setArtifactPanelTabs((tabs) => { const nextTabs = tabs.filter((tab) => tab.id !== tabId); if (nextTabs.length === 0) { @@ -13119,6 +13146,9 @@ export function App({ @@ -13188,6 +13218,9 @@ export function App({
diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css index 98deb7db6bd..94b14a423f3 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css @@ -231,6 +231,17 @@ padding: 0; } +.terminalPane { + height: 100%; +} + +.terminalPaneHidden { + position: absolute; + inset: 12px; + visibility: hidden; + pointer-events: none; +} + .attachmentPreviewButton { position: sticky; float: right; diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx index 891c3c119da..76b1ff02919 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.test.tsx @@ -85,6 +85,12 @@ vi.mock( }), ); +vi.mock('../terminal/TerminalPanel', () => ({ + TerminalPanel: ({ terminalId }: { terminalId: string }) => ( +
+ ), +})); + const { ArtifactPanel } = await import('./ArtifactPanel'); const { useArtifactWorkspaceTarget } = await import( './useArtifactWorkspaceTarget' @@ -363,6 +369,49 @@ afterEach(() => { }; }); +describe('ArtifactPanel terminal tabs', () => { + const renderPanel = (activeTabId: string) => ( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + + ); + + it('keeps terminal instances mounted while switching tabs', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + + act(() => root.render(renderPanel('terminal-one'))); + const first = container.querySelector('[data-terminal-id="terminal-one"]'); + expect( + container.querySelectorAll('[data-testid="terminal-panel"]'), + ).toHaveLength(2); + + act(() => root.render(renderPanel('terminal-two'))); + + expect(container.querySelector('[data-terminal-id="terminal-one"]')).toBe( + first, + ); + expect( + container.querySelectorAll('[data-testid="terminal-panel"]'), + ).toHaveLength(2); + }); +}); + describe('artifact workspace authority', () => { it('keeps an in-flight read across an equivalent capabilities refresh', async () => { let resolveRead: @@ -1438,6 +1487,52 @@ describe('ArtifactPanel scheduled-task ownership', () => { }); describe('ArtifactPanel add menu', () => { + it('opens terminals without forwarding UI events as terminal ids', () => { + const onOpenTerminal = vi.fn(); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const renderPanel = (withTab: boolean) => ( + + {}} + onCloseTab={() => {}} + onOpenFilePreview={() => {}} + onClose={() => {}} + /> + + ); + + act(() => root.render(renderPanel(false))); + const emptyAction = Array.from( + container.querySelectorAll( + '[data-testid="right-panel-empty-actions"] button', + ), + ).find((button) => button.textContent?.includes('Terminal')); + act(() => emptyAction?.click()); + expect(onOpenTerminal).toHaveBeenLastCalledWith(); + + act(() => root.render(renderPanel(true))); + openAddMenu(container); + const menuAction = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent === 'Terminal'); + act(() => menuAction?.click()); + expect(onOpenTerminal).toHaveBeenLastCalledWith(); + expect(onOpenTerminal).toHaveBeenCalledTimes(2); + }); + it('keeps the disabled review action on the empty page and hides the add button', () => { const onClose = vi.fn(); const container = document.createElement('div'); diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index be4f2de4569..95419bfdc3a 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -86,6 +86,7 @@ import styles from './ArtifactPanel.module.css'; import { CodeReviewArtifactDetail } from './CodeReviewArtifactDetail'; import { SubagentDetail } from './SubagentDetail'; import { SideTaskPanel } from './SideTaskPanel'; +import { TerminalPanel } from '../terminal/TerminalPanel'; import { useArtifactWorkspaceTarget, type ArtifactWorkspaceActions, @@ -195,6 +196,12 @@ export type ArtifactPanelTab = workspaceCwd?: string; nameFromFirstPrompt?: boolean; initialPrompt?: string; + } + | { + id: string; + kind: 'terminal'; + title: string; + workspaceCwd?: string; }; type WorkspaceScopedArtifactPanelTab = Extract< @@ -250,6 +257,8 @@ interface ArtifactPanelProps { ) => void; latestReviewAvailable?: boolean; onOpenLatestReview?: () => void; + /** Open an interactive terminal tab in this panel (shown as an empty-state action). */ + onOpenTerminal?: () => void; items?: readonly WebShellRightPanelItem[]; sideTaskAvailable?: boolean; sideTasks?: readonly SideTaskListItem[]; @@ -297,6 +306,7 @@ export function ArtifactPanel({ onOpenFilePreview, latestReviewAvailable = false, onOpenLatestReview, + onOpenTerminal, items = DEFAULT_RIGHT_PANEL_ITEMS, sideTaskAvailable = false, sideTasks = [], @@ -367,8 +377,10 @@ export function ArtifactPanel({ items.includes('sideTask') && sideTaskAvailable && Boolean(onCreateSideTask); + const showTerminalMenuItem = Boolean(onOpenTerminal); const showAddMenu = - Boolean(activeTab) && (showReviewMenuItem || showSideTaskMenuItems); + Boolean(activeTab) && + (showReviewMenuItem || showSideTaskMenuItems || showTerminalMenuItem); const activeWorkspaceIdentity = activeTab && isWorkspaceScopedTab(activeTab) ? { @@ -443,6 +455,11 @@ export function ArtifactPanel({ className={styles.tabIconSvg} strokeWidth={1.6} /> + ) : tab.kind === 'terminal' ? ( + ) : tab.kind === 'image' ? ( )} + {showTerminalMenuItem && + (showReviewMenuItem || showSideTaskMenuItems) && ( + + )} + {showTerminalMenuItem && ( + onOpenTerminal?.()}> + + )} )} @@ -550,6 +583,22 @@ export function ArtifactPanel({ activeTab?.kind === 'side_task' ? styles.bodySideTask : '' }`.trim()} > + {tabs + .filter((tab) => tab.kind === 'terminal') + .map((tab) => ( +
+ +
+ ))} {canPreviewAttachment && ( + )}
) : isWorkspaceScopedTab(activeTab) && (activeTab.kind !== 'scheduled_task' || activeTab.task.durable) && diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx new file mode 100644 index 00000000000..5dfb7460ebd --- /dev/null +++ b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const terminal = vi.hoisted(() => ({ + options: {} as Record, + cols: 80, + rows: 24, + loadAddon: vi.fn(), + open: vi.fn(), + reset: vi.fn(), + write: vi.fn(), + writeln: vi.fn(), + focus: vi.fn(), + dispose: vi.fn(), + onData: vi.fn((_listener: (data: string) => void) => ({ dispose: vi.fn() })), +})); + +vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(() => terminal) })); +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: vi.fn(() => ({ fit: vi.fn() })), +})); +vi.mock('../../themeContext', () => ({ useTheme: () => 'light' })); +vi.mock('../../config/daemon', () => ({ getDaemonToken: () => '' })); + +import { releaseWebTerminal, TerminalPanel } from './TerminalPanel'; + +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 3; + static readonly instances: FakeWebSocket[] = []; + + readyState = FakeWebSocket.CONNECTING; + binaryType: BinaryType = 'blob'; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + readonly send = vi.fn(); + readonly close = vi.fn(() => { + this.readyState = FakeWebSocket.CLOSED; + }); + + constructor( + readonly url: string, + readonly protocols?: string | string[], + ) { + FakeWebSocket.instances.push(this); + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + } + + message(data: string | Blob | ArrayBuffer): void { + this.onmessage?.({ data } as MessageEvent); + } + + closeWith(code: number): void { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.({ code } as CloseEvent); + } +} + +describe('TerminalPanel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + FakeWebSocket.instances.length = 0; + vi.stubGlobal('WebSocket', FakeWebSocket); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + function render(): FakeWebSocket { + act(() => { + root.render(); + }); + return FakeWebSocket.instances[0]!; + } + + it('keeps binary PTY output distinct from text control frames', async () => { + const ws = render(); + act(() => ws.open()); + + await act(async () => { + ws.message( + new TextEncoder().encode('\x00{"type":"exit","exitCode":0}').buffer, + ); + ws.message('\x00{"type":"error","message":"denied"}'); + await Promise.resolve(); + }); + + expect(terminal.write).toHaveBeenCalledWith( + '\x00{"type":"exit","exitCode":0}', + ); + expect(terminal.writeln).toHaveBeenCalledWith( + expect.stringContaining('[Error: denied]'), + ); + + const handleInput = terminal.onData.mock.calls.at(-1)?.[0]; + ws.send.mockClear(); + act(() => handleInput?.('\x00{"type":"release"}')); + const sent = ws.send.mock.calls[0]?.[0]; + expect(ArrayBuffer.isView(sent)).toBe(true); + expect(new TextDecoder().decode(sent as Uint8Array)).toBe( + '\x00{"type":"release"}', + ); + }); + + it.each([4000, 4001, 4002, 4003, 4004])( + 'does not reconnect after non-retryable close code %s', + async (code) => { + const ws = render(); + act(() => ws.open()); + act(() => ws.closeWith(code)); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(FakeWebSocket.instances).toHaveLength(1); + }, + ); + + it('releases a connecting socket without reconnecting afterward', async () => { + const ws = render(); + act(() => releaseWebTerminal('terminal:one')); + + act(() => ws.open()); + expect(ws.send).toHaveBeenCalledWith('\x00{"type":"release"}'); + act(() => ws.closeWith(1000)); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + it('reconnects transient failures without clearing the notice early', async () => { + const ws = render(); + act(() => ws.open()); + terminal.reset.mockClear(); + act(() => ws.closeWith(1006)); + + expect(terminal.writeln).toHaveBeenCalledWith( + expect.stringContaining('Connection lost'), + ); + expect(terminal.reset).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTimeAsync(500)); + expect(FakeWebSocket.instances).toHaveLength(2); + act(() => FakeWebSocket.instances[1]!.open()); + expect(terminal.reset).toHaveBeenCalledOnce(); + }); + + it('sends an explicit release control when the tab is closed', () => { + const ws = render(); + act(() => ws.open()); + + act(() => releaseWebTerminal('terminal:one')); + + expect(ws.send).toHaveBeenCalledWith('\x00{"type":"release"}'); + }); +}); diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.tsx new file mode 100644 index 00000000000..a8868cf3301 --- /dev/null +++ b/packages/web-shell/client/components/terminal/TerminalPanel.tsx @@ -0,0 +1,315 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef } from 'react'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; +import { useTheme, type WebShellTheme } from '../../themeContext'; +import { getDaemonToken } from '../../config/daemon'; + +interface TerminalPanelProps { + /** Stable id shared with the backend PTY session; reconnects reuse it. */ + terminalId: string; + cwd?: string; +} + +const CONTROL_FRAME_PREFIX = '\x00'; +/** Exponential backoff bounds for automatic WebSocket reconnection. */ +const RECONNECT_INITIAL_MS = 500; +const RECONNECT_MAX_MS = 10_000; +const NON_RETRYABLE_CLOSE_CODES = new Set([4000, 4001, 4002, 4003, 4004]); +const releaseCallbacks = new Map void>(); + +export function releaseWebTerminal(terminalId: string): void { + releaseCallbacks.get(terminalId)?.(); +} + +// Browsers cannot set Authorization on a WebSocket. The daemon decodes this +// bearer subprotocol during the upgrade; keep the prefix in sync with it +// (see packages/cli/src/serve/acp-http/index.ts). +const WS_BEARER_SUBPROTOCOL_PREFIX = 'qwen-bearer.'; +const WS_AUTH_SUBPROTOCOL = 'qwen-ws'; + +function bearerSubprotocol(token: string): string { + const bytes = new TextEncoder().encode(token); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const b64 = btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${WS_BEARER_SUBPROTOCOL_PREFIX}${b64}`; +} + +function wsProtocols(): string[] { + const token = getDaemonToken(); + return token ? [WS_AUTH_SUBPROTOCOL, bearerSubprotocol(token)] : []; +} + +function buildWsUrl(terminalId: string, cwd: string | undefined): string { + const isSecure = window.location.protocol === 'https:'; + const base = `${isSecure ? 'wss:' : 'ws:'}//${window.location.host}`; + const url = new URL('/terminal', base); + url.searchParams.set('terminalId', terminalId); + if (cwd) url.searchParams.set('cwd', cwd); + return url.toString(); +} + +function xtermTheme(theme: WebShellTheme) { + return theme === 'light' + ? { + background: '#ffffff', + foreground: '#1a1a1a', + cursor: '#1a1a1a', + } + : { + background: '#0a0a0a', + foreground: '#e0e6f0', + cursor: '#e0e6f0', + }; +} + +export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { + const theme = useTheme(); + const containerRef = useRef(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + + // Follow web-shell theme switches without rebuilding the terminal or + // dropping the WebSocket connection. + useEffect(() => { + const term = termRef.current; + if (term) term.options.theme = xtermTheme(theme); + }, [theme]); + + useEffect(() => { + if (!containerRef.current) return; + + const term = new Terminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'var(--font-mono, Menlo, Monaco, "Courier New", monospace)', + theme: xtermTheme(theme), + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(containerRef.current); + termRef.current = term; + fitRef.current = fit; + + requestAnimationFrame(() => { + try { + fit.fit(); + } catch { + // Container may not be laid out yet — retry on next frame. + requestAnimationFrame(() => fit.fit()); + } + }); + + const wsUrl = buildWsUrl(terminalId, cwd); + const protocols = wsProtocols(); + + let disposed = false; + let reconnectTimer: ReturnType | undefined; + let reconnectDelay = RECONNECT_INITIAL_MS; + let lostNoticeWritten = false; + let resizeTimeout: ReturnType | undefined; + let releaseRequested = false; + let ended = false; + + function handleControl(raw: string): boolean { + try { + const ctrl = JSON.parse(raw.slice(CONTROL_FRAME_PREFIX.length)) as { + type?: unknown; + exitCode?: unknown; + message?: unknown; + }; + if (ctrl.type === 'exit') { + ended = true; + const exitCode = + typeof ctrl.exitCode === 'number' ? String(ctrl.exitCode) : '?'; + term.writeln( + `\r\n\x1b[33m[Process exited with code ${exitCode}]\x1b[0m`, + ); + return true; + } else if (ctrl.type === 'error') { + const message = + typeof ctrl.message === 'string' ? ctrl.message : 'Unknown error'; + term.writeln(`\r\n\x1b[31m[Error: ${message}]\x1b[0m`); + return true; + } + } catch { + // Preserve malformed NUL-prefixed PTY output below. + } + return false; + } + + function writeMessage(text: string) { + if (!text.startsWith(CONTROL_FRAME_PREFIX) || !handleControl(text)) { + term.write(text); + } + } + + function handleMessage(event: MessageEvent) { + if (typeof event.data === 'string') { + writeMessage(event.data); + } else { + term.write(new TextDecoder().decode(event.data as ArrayBuffer)); + } + } + + function connect() { + if (disposed) return; + const ws = new WebSocket( + wsUrl, + protocols.length > 0 ? protocols : undefined, + ); + ws.binaryType = 'arraybuffer'; + wsRef.current = ws; + + ws.onopen = () => { + if (releaseRequested) { + ws.send(CONTROL_FRAME_PREFIX + JSON.stringify({ type: 'release' })); + ws.close(); + return; + } + if (disposed) { + ws.close(); + return; + } + // Keep the reconnect notice visible until a connection succeeds; the + // backend immediately replays the complete scrollback after this. + term.reset(); + reconnectDelay = RECONNECT_INITIAL_MS; + lostNoticeWritten = false; + if (ws.readyState === WebSocket.OPEN) { + ws.send( + CONTROL_FRAME_PREFIX + + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + }), + ); + } + }; + + ws.onmessage = handleMessage; + + ws.onerror = () => { + // Errors surface through close; reconnect is handled there. + }; + + ws.onclose = (event) => { + if (disposed || releaseRequested) return; + if (ended || NON_RETRYABLE_CLOSE_CODES.has(event.code)) return; + if (!lostNoticeWritten) { + term.writeln('\r\n\x1b[33m[Connection lost — reconnecting…]\x1b[0m'); + lostNoticeWritten = true; + } + reconnectTimer = setTimeout(connect, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); + }; + } + + connect(); + + const release = () => { + if (releaseRequested) return; + releaseRequested = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(CONTROL_FRAME_PREFIX + JSON.stringify({ type: 'release' })); + ws.close(); + } else if (!ws || ws.readyState === WebSocket.CLOSED) { + connect(); + } + }; + releaseCallbacks.set(terminalId, release); + + // xterm → WebSocket (raw keystrokes = stdin, control = resize) + const disposable = term.onData((data: string) => { + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(new TextEncoder().encode(data)); + } + }); + + // Resize observer → send new size + const resizeObserver = new ResizeObserver(() => { + if (!fitRef.current || !termRef.current) return; + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + try { + fitRef.current?.fit(); + const ws = wsRef.current; + const current = termRef.current; + if (!current || !ws || ws.readyState !== WebSocket.OPEN) return; + ws.send( + CONTROL_FRAME_PREFIX + + JSON.stringify({ + type: 'resize', + cols: current.cols, + rows: current.rows, + }), + ); + } catch { + // Fit can throw if the terminal was disposed. + } + }, 100); + }); + resizeObserver.observe(containerRef.current); + + term.focus(); + + return () => { + disposed = true; + if (releaseCallbacks.get(terminalId) === release) { + releaseCallbacks.delete(terminalId); + } + if (reconnectTimer) clearTimeout(reconnectTimer); + clearTimeout(resizeTimeout); + resizeObserver.disconnect(); + disposable.dispose(); + if ( + !releaseRequested || + wsRef.current?.readyState !== WebSocket.CONNECTING + ) { + wsRef.current?.close(); + } + term.dispose(); + termRef.current = null; + fitRef.current = null; + wsRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+
+ ); +} diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 695cce497c1..d815e66f66b 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -125,7 +125,7 @@ export interface WebShellChatHeaderOptions { items?: readonly WebShellChatHeaderItem[]; } -export type WebShellRightPanelItem = 'review' | 'sideTask'; +export type WebShellRightPanelItem = 'review' | 'sideTask' | 'terminal'; export interface WebShellRightPanelOptions { /** Empty-state actions to show. Defaults to all actions. */ diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 94da9703717..a6627cbd2e2 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1226,6 +1226,8 @@ const EN: Messages = { 'sideTask.description': 'View or create side tasks', 'sideTask.new': 'New', 'sideTask.create': 'New side task', + 'terminal.title': 'Terminal', + 'terminal.open': 'Open a terminal', 'rightPanel.add': 'Add panel', 'attachment.showPreview': 'Preview', 'attachment.showSource': 'Source', @@ -4243,6 +4245,8 @@ const ZH: Messages = { 'sideTask.description': '查看或新增侧边任务', 'sideTask.new': '新增', 'sideTask.create': '新建侧边任务', + 'terminal.title': '终端', + 'terminal.open': '打开终端', 'rightPanel.add': '添加页签', 'attachment.showPreview': '预览', 'attachment.showSource': '源码', diff --git a/packages/web-shell/client/main.tsx b/packages/web-shell/client/main.tsx index da497fb6b51..02ae69872a7 100644 --- a/packages/web-shell/client/main.tsx +++ b/packages/web-shell/client/main.tsx @@ -175,7 +175,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) { items: ['title', 'environment', 'rightPanel'], }, rightPanel: { - items: ['review', 'sideTask'], + items: ['review', 'sideTask', 'terminal'], }, environmentPanel: { items: ['environment', 'subagents', 'backgroundTasks'], diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index 58982f07892..d6454409964 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -43,6 +43,8 @@ "@datafe-open/markdown-chart-echarts": "^0.1.12", "@datafe-open/markdown-chart-react": "^0.1.12", "@tanstack/react-virtual": "^3.13.26", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.0", diff --git a/packages/web-shell/vite.config.ts b/packages/web-shell/vite.config.ts index 9f018324e40..9566d124c5b 100644 --- a/packages/web-shell/vite.config.ts +++ b/packages/web-shell/vite.config.ts @@ -117,6 +117,9 @@ export default defineConfig(({ command }) => ({ // `client/voice/*` source modules (e.g. `/voice/voiceModels.ts`), which // vite must serve, and blanks the page. '/voice/stream': { ...daemonProxy, ws: true }, + // Interactive terminal WebSocket (`/terminal`); `ws: true` forwards the + // HTTP upgrade to the daemon, same as `/voice/stream`. + '/terminal': { ...daemonProxy, ws: true }, }, }, })); From f5819dcd27aa63c0e8cc582470d305280a855de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 14:51:49 +0800 Subject: [PATCH 02/11] chore(vscode): update third-party notices --- packages/vscode-ide-companion/NOTICES.txt | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 540a01bbc94..4b718c3263c 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -19915,6 +19915,58 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +@xterm/addon-fit@0.11.0 +(https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) + +Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +@xterm/xterm@6.0.0 +(https://github.com/xtermjs/xterm.js) + +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ============================================================ class-variance-authority@0.7.1 (https://github.com/joe-bell/cva.git) From 10f088e2ba9c0c42c788bd81d2986d515f013686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 15:20:41 +0800 Subject: [PATCH 03/11] test(cli): include web terminal capability --- packages/cli/src/serve/server.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 64f4144b277..61ba1540a0c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -2756,6 +2756,7 @@ describe('createServeApp', () => { // baseline that older clients see on a default-loopback daemon. expect(getAdvertisedServeFeatures()).toEqual([ ...EXPECTED_STAGE1_FEATURES, + 'web_terminal', ]); expect(getServeFeatures()).toEqual(getAdvertisedServeFeatures()); }); From c0114c0876fb758305c80133c716dd123f0d8d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 15:51:38 +0800 Subject: [PATCH 04/11] test(integration): include web terminal capability --- integration-tests/cli/qwen-serve-routes.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 084c1fe1a48..c5da37bd02d 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -414,6 +414,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_session_live_state', 'workspace_session_metadata', 'voice_transcribe', + 'web_terminal', ]); }); }); From 50a15d0a14189b618c2e1da2aeca0dc5868c0211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Tue, 25 Aug 2026 20:35:57 +0800 Subject: [PATCH 05/11] fix(web-shell): harden terminal lifecycle --- docs/design/web-terminal-lifecycle.md | 4 +- packages/cli/src/serve/capabilities.ts | 1 + .../cli/src/serve/routes/terminal.test.ts | 151 +++++++++++++++++- packages/cli/src/serve/routes/terminal.ts | 57 +++++-- packages/cli/src/serve/server.test.ts | 28 +++- .../services/web-terminal-registry.test.ts | 79 ++++++++- .../src/services/web-terminal-registry.ts | 146 +++++++++++++---- packages/web-shell/client/App.tsx | 5 + .../components/artifacts/ArtifactPanel.tsx | 1 + .../terminal/TerminalPanel.test.tsx | 99 +++++++++++- .../components/terminal/TerminalPanel.tsx | 101 ++++++++---- packages/web-shell/client/i18n.tsx | 9 ++ 12 files changed, 585 insertions(+), 96 deletions(-) diff --git a/docs/design/web-terminal-lifecycle.md b/docs/design/web-terminal-lifecycle.md index 83bd1ec3402..8c9121fbad7 100644 --- a/docs/design/web-terminal-lifecycle.md +++ b/docs/design/web-terminal-lifecycle.md @@ -15,4 +15,6 @@ PTY. Hiding the panel, switching tabs or losing the WebSocket only detaches the client, so reconnect can replay the same session. A detached session is killed after the idle timeout even if its command is still running. Exited sessions replay both scrollback and exit state. Binary WebSocket frames contain UTF-8 -terminal input and output; text frames are reserved for control messages. +terminal input and output; NUL-prefixed text frames contain control messages. +Reloading the page loses the in-memory terminal id, so the detached session is +reclaimed after the idle timeout rather than restored into the new page. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index e7c944c7d73..7feffde0c4b 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -672,6 +672,7 @@ export const CONDITIONAL_SERVE_FEATURES: ReadonlyMap< (toggles) => toggles.acpHttpEnabled === true && toggles.realtimeVoiceEnabled === true, ], + ['web_terminal', (toggles) => toggles.acpHttpEnabled === true], ]); export const SERVE_FEATURES = Object.freeze( diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts index be32c807c10..a019fc63359 100644 --- a/packages/cli/src/serve/routes/terminal.test.ts +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -24,6 +24,7 @@ const request = { class FakeWebSocket extends EventEmitter { readonly OPEN = 1; readonly readyState = this.OPEN; + bufferedAmount = 0; readonly sent: unknown[] = []; readonly send = vi.fn((data: unknown) => this.sent.push(data)); readonly close = vi.fn(); @@ -50,8 +51,8 @@ function registryWithSnapshot( readSnapshot: vi.fn(() => snapshot), addOutputListener: vi.fn(() => vi.fn()), addExitListener: vi.fn(() => vi.fn()), - resize: vi.fn(), - write: vi.fn(), + resize: vi.fn(() => true), + write: vi.fn(() => true), release: vi.fn(), releaseWorkspace: vi.fn(), } as unknown as WebTerminalRegistry; @@ -122,6 +123,51 @@ describe('terminal WebSocket route', () => { ); }); + it('handles socket errors while terminal creation is pending', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + + expect(() => ws.emit('error', new Error('socket failed'))).not.toThrow(); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(registry.addOutputListener).not.toHaveBeenCalled(); + }); + + it('releases an exited session from the connection handshake', async () => { + const registry = registryWithSnapshot({ + output: 'done', + exited: true, + exitCode: 0, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + { + url: `${request.url}&release=1`, + } as IncomingMessage, + ); + + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(registry.addOutputListener).not.toHaveBeenCalled(); + expect(ws.close).toHaveBeenCalledWith(4004, 'Terminal released'); + }); + it('replays the exit state and closes instead of swallowing input', async () => { const registry = registryWithSnapshot({ output: 'done\r\n', @@ -361,7 +407,7 @@ describe('terminal WebSocket route', () => { resolveCreate?.({ terminalId: 'terminal:manual-1' }); await connected; - expect(ws.close).toHaveBeenCalledWith(4001, 'Terminal input too large'); + expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal input too large'); expect(registry.release).toHaveBeenCalledWith( 'terminal:manual-1', '/workspace', @@ -369,6 +415,105 @@ describe('terminal WebSocket route', () => { expect(registry.addOutputListener).not.toHaveBeenCalled(); }); + it('flushes a resize buffered while PTY creation is pending', async () => { + let resolveCreate: ((value: { terminalId: string }) => void) | undefined; + const registry = registryWithSnapshot(undefined); + vi.mocked(registry.create).mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + vi.mocked(registry.readSnapshot) + .mockReturnValueOnce(undefined) + .mockReturnValue({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + const ws = new FakeWebSocket(); + const connected = createTerminalWsHandler( + registry, + resolveWorkspace, + ).onConnection(ws as unknown as WebSocket, request); + + ws.emit('message', '\x00{"type":"resize","cols":120,"rows":40}'); + resolveCreate?.({ terminalId: 'terminal:manual-1' }); + await connected; + + expect(registry.resize).toHaveBeenCalledWith('terminal:manual-1', 120, 40); + }); + + it('closes and releases a session when PTY input backpressure is hit', async () => { + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.write).mockReturnValueOnce(false); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + ws.emit('message', Buffer.from('large paste'), true); + + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); + expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal input backpressure'); + }); + + it('detaches and closes a stalled output connection', async () => { + let output: ((data: string) => void) | undefined; + const detachOutput = vi.fn(); + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addOutputListener).mockImplementationOnce( + (_terminalId, listener) => { + output = listener; + return detachOutput; + }, + ); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + ws.bufferedAmount = 1024 * 1024 + 1; + + output?.('more output'); + + expect(detachOutput).toHaveBeenCalledOnce(); + expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal output backpressure'); + }); + + it('detaches listeners after an attached socket closes', async () => { + const detachOutput = vi.fn(); + const detachExit = vi.fn(); + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addOutputListener).mockReturnValueOnce(detachOutput); + vi.mocked(registry.addExitListener).mockReturnValueOnce(detachExit); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + ws.emit('close'); + + expect(detachOutput).toHaveBeenCalledOnce(); + expect(detachExit).toHaveBeenCalledOnce(); + }); + it('prevents a terminal id from being claimed by another workspace', async () => { const registry = registryWithSnapshot({ output: 'secret', diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts index 3f3af5d1c84..5a870354c09 100644 --- a/packages/cli/src/serve/routes/terminal.ts +++ b/packages/cli/src/serve/routes/terminal.ts @@ -13,6 +13,7 @@ const TERMINAL_WS_PATH = '/terminal'; const CONTROL_FRAME_PREFIX = '\x00'; const MAX_PENDING_INPUT_BYTES = 64 * 1024; const MAX_TERMINAL_DIMENSION = 1000; +const MAX_SOCKET_BUFFERED_BYTES = 1024 * 1024; export interface WebTerminalWorkspaceContext { workspaceCwd: string; @@ -23,8 +24,15 @@ type TerminalControl = | { type: 'resize'; cols: number; rows: number } | { type: 'release' }; -function sendOutput(ws: WebSocket, text: string): void { - if (ws.readyState === ws.OPEN) ws.send(Buffer.from(text)); +function sendOutput(ws: WebSocket, text: string): boolean { + if ( + ws.readyState !== ws.OPEN || + ws.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES + ) { + return false; + } + ws.send(Buffer.from(text)); + return true; } function sendControl(ws: WebSocket, payload: unknown): void { @@ -58,7 +66,7 @@ function parseControl(data: string): TerminalControl | null { }; } } catch { - // Not a valid control frame; preserve it as terminal input. + // Malformed control frames are dropped by the caller. } return null; } @@ -85,9 +93,15 @@ export function createTerminalWsHandler( path: TERMINAL_WS_PATH, bypassPrimaryDrain: true, onConnection: async (ws: WebSocket, req: IncomingMessage) => { + let closed = false; + const markClosed = () => { + closed = true; + }; + ws.on('error', markClosed); const url = new URL(req.url ?? '', 'http://localhost'); const terminalId = url.searchParams.get('terminalId'); const selector = url.searchParams.get('cwd'); + const releaseOnly = url.searchParams.get('release') === '1'; const workspace = selector ? resolveWorkspace(selector) : undefined; if (!terminalId || terminalId.length > 256 || !selector || !workspace) { sendControl(ws, { @@ -97,17 +111,18 @@ export function createTerminalWsHandler( ws.close(4002, 'Terminal workspace unavailable'); return; } + if (releaseOnly) { + registry.release(terminalId, workspace.workspaceCwd); + ws.close(4004, 'Terminal released'); + return; + } const workspaceSelector = selector; - let closed = false; let created = false; let inputRejected = false; let releaseRequested = false; let pendingBytes = 0; const pending: Array<{ text: string; isBinary: boolean }> = []; - const markClosed = () => { - closed = true; - }; const bufferMessage = (data: unknown, isBinary = false) => { const text = toText(data); if (!isBinary && parseControl(text)?.type === 'release') { @@ -120,7 +135,7 @@ export function createTerminalWsHandler( type: 'error', message: 'Terminal input too large', }); - ws.close(4001, 'Terminal input too large'); + ws.close(1013, 'Terminal input too large'); return; } pending.push({ text, isBinary }); @@ -230,12 +245,23 @@ export function createTerminalWsHandler( ws.close(4002, 'Terminal workspace unavailable'); return; } - registry.write(terminalId, text); + if (!registry.write(terminalId, text)) { + sendControl(ws, { + type: 'error', + message: 'Terminal input backpressure', + }); + cleanup(); + registry.release(terminalId, workspace.workspaceCwd); + ws.close(1013, 'Terminal input backpressure'); + } }; - detach.output = registry.addOutputListener(terminalId, (data) => - sendOutput(ws, data), - ); + detach.output = registry.addOutputListener(terminalId, (data) => { + if (!sendOutput(ws, data)) { + cleanup(); + ws.close(1013, 'Terminal output backpressure'); + } + }); if (!detach.output) { sendControl(ws, { type: 'error', message: 'Session unavailable' }); ws.close(4002, 'Session unavailable'); @@ -255,8 +281,13 @@ export function createTerminalWsHandler( ws.off('message', bufferMessage); ws.on('message', onMessage); ws.on('close', cleanup); + ws.off('error', markClosed); ws.on('error', cleanup); - sendOutput(ws, snapshot.output); + if (!sendOutput(ws, snapshot.output)) { + cleanup(); + ws.close(1013, 'Terminal output backpressure'); + return; + } if (snapshot.exited) { finishExited(snapshot.exitCode); return; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 61ba1540a0c..a2ff5efa79c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -2754,10 +2754,7 @@ describe('createServeApp', () => { // Conditional tags (currently `require_auth`) are absent unless // a runtime toggle is supplied; this is the "no toggles passed" // baseline that older clients see on a default-loopback daemon. - expect(getAdvertisedServeFeatures()).toEqual([ - ...EXPECTED_STAGE1_FEATURES, - 'web_terminal', - ]); + expect(getAdvertisedServeFeatures()).toEqual(EXPECTED_STAGE1_FEATURES); expect(getServeFeatures()).toEqual(getAdvertisedServeFeatures()); }); @@ -2794,10 +2791,13 @@ describe('createServeApp', () => { ).toContain('voice_transcribe'); }); - it('advertises `web_terminal` for frontend version compatibility', () => { - expect(getAdvertisedServeFeatures(undefined, {})).toContain( - 'web_terminal', - ); + it('advertises `web_terminal` only with the ACP HTTP surface', () => { + expect( + getAdvertisedServeFeatures(undefined, { acpHttpEnabled: true }), + ).toContain('web_terminal'); + expect( + getAdvertisedServeFeatures(undefined, { acpHttpEnabled: false }), + ).not.toContain('web_terminal'); }); it('honors every entry in CONDITIONAL_SERVE_FEATURES (PR #4236 review #3254467192 — drift insurance)', () => { @@ -3382,6 +3382,18 @@ describe('createServeApp', () => { ).not.toContain(feature); continue; } + if (feature === 'web_terminal') { + expect(predicate({ acpHttpEnabled: true })).toBe(true); + expect(predicate({ acpHttpEnabled: false })).toBe(false); + expect(predicate({})).toBe(false); + expect( + getAdvertisedServeFeatures(undefined, { acpHttpEnabled: true }), + ).toContain(feature); + expect( + getAdvertisedServeFeatures(undefined, { acpHttpEnabled: false }), + ).not.toContain(feature); + continue; + } // Future conditional tag. Authors must add a branch above with // the toggle field that drives this predicate. Failing here is // intentional: it forces the new conditional tag to ship with a diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts index 9c7d0687be3..3e30ac01ecd 100644 --- a/packages/core/src/services/web-terminal-registry.test.ts +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -25,12 +25,16 @@ describe('WebTerminalRegistry', () => { let write: ReturnType; let resize: ReturnType; let kill: ReturnType; + let disposeData: ReturnType; + let disposeExit: ReturnType; beforeEach(() => { vi.clearAllMocks(); write = vi.fn(); resize = vi.fn(); kill = vi.fn(); + disposeData = vi.fn(); + disposeExit = vi.fn(); spawn.mockReturnValue({ pid: 1, write, @@ -38,9 +42,11 @@ describe('WebTerminalRegistry', () => { kill, onData: vi.fn((listener) => { onData = listener; + return { dispose: disposeData }; }), onExit: vi.fn((listener) => { onExit = listener; + return { dispose: disposeExit }; }), }); getPty.mockResolvedValue({ module: { spawn }, name: 'node-pty' }); @@ -150,7 +156,10 @@ describe('WebTerminalRegistry', () => { terminalId: 'terminal:over-limit', workspaceCwd: '/workspace', }), - ).resolves.toEqual({ error: 'Web terminal limit reached' }); + ).resolves.toEqual({ + error: 'Web terminal limit reached', + retryable: true, + }); resolvePty?.({ module: { spawn }, name: 'node-pty' }); await pending; @@ -210,7 +219,9 @@ describe('WebTerminalRegistry', () => { onData('界'.repeat(1_500_000)); - expect(registry.readSnapshot('terminal:utf8-buffer')?.output).toBe(''); + const output = registry.readSnapshot('terminal:utf8-buffer')?.output ?? ''; + expect(output.length).toBeGreaterThan(0); + expect(Buffer.byteLength(output)).toBeLessThanOrEqual(4 * 1024 * 1024); }); it('releases a live PTY immediately and only once', async () => { @@ -223,9 +234,46 @@ describe('WebTerminalRegistry', () => { expect(registry.release('terminal:release')).toBe(true); expect(registry.release('terminal:release')).toBe(false); expect(kill).toHaveBeenCalledOnce(); + expect(disposeData).toHaveBeenCalledOnce(); + expect(disposeExit).toHaveBeenCalledOnce(); expect(registry.readSnapshot('terminal:release')).toBeUndefined(); }); + it('forwards live output and bounds unacknowledged PTY input', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:io', + workspaceCwd: '/workspace', + }); + const listener = vi.fn(); + registry.addOutputListener('terminal:io', listener); + + expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe(true); + expect(registry.write('terminal:io', 'x')).toBe(false); + expect(write).toHaveBeenCalledOnce(); + expect(registry.resize('terminal:io', 120, 40)).toBe(true); + expect(resize).toHaveBeenCalledWith(120, 40); + + onData('ready'); + expect(listener).toHaveBeenCalledWith('ready'); + expect(registry.write('terminal:io', 'x')).toBe(true); + }); + + it('preserves a clean exit code in snapshots', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:clean-exit', + workspaceCwd: '/workspace', + }); + + onExit({ exitCode: 0 }); + + expect(registry.readSnapshot('terminal:clean-exit')).toMatchObject({ + exited: true, + exitCode: 0, + }); + }); + it('does not spawn a PTY after disposal wins an in-flight create', async () => { let resolvePty: ((value: unknown) => void) | undefined; getPty.mockReturnValueOnce( @@ -346,10 +394,8 @@ describe('WebTerminalRegistry', () => { workspaceCwd: '/workspace-a', }); const killA = kill; - const exitA = onExit; const exitListener = vi.fn(); registry.addExitListener('terminal:a', exitListener); - killA.mockImplementation(() => exitA({ exitCode: 0 })); await registry.create({ terminalId: 'terminal:b', workspaceCwd: '/workspace-b', @@ -358,7 +404,7 @@ describe('WebTerminalRegistry', () => { registry.releaseWorkspace('/workspace-a'); expect(killA).toHaveBeenCalledOnce(); - expect(exitListener).toHaveBeenCalledWith({ exitCode: 0 }); + expect(exitListener).toHaveBeenCalledWith({ exitCode: 143, signal: 15 }); expect(registry.readSnapshot('terminal:a')).toBeUndefined(); expect(registry.readSnapshot('terminal:b')).toBeDefined(); }); @@ -391,4 +437,27 @@ describe('WebTerminalRegistry', () => { expect(kill).toHaveBeenCalledOnce(); expect(registry.readSnapshot('terminal:idle')).toBeUndefined(); }); + + it('does not let a stale detach reclaim a reused terminal id', async () => { + vi.useFakeTimers(); + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:reused', + workspaceCwd: '/workspace', + }); + const detach = registry.addOutputListener('terminal:reused', vi.fn()); + + registry.release('terminal:reused'); + detach?.(); + kill.mockClear(); + await registry.create({ + terminalId: 'terminal:reused', + workspaceCwd: '/workspace', + }); + registry.addOutputListener('terminal:reused', vi.fn()); + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + + expect(kill).not.toHaveBeenCalled(); + expect(registry.readSnapshot('terminal:reused')).toBeDefined(); + }); }); diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts index f1712e2c005..3739daf27fe 100644 --- a/packages/core/src/services/web-terminal-registry.ts +++ b/packages/core/src/services/web-terminal-registry.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { spawnSync } from 'node:child_process'; import { getPty } from '../utils/getPty.js'; /** @@ -27,8 +28,6 @@ export interface CreateWebTerminalOptions { terminalId?: string; workspaceCwd: string; env?: Readonly; - cols?: number; - rows?: number; } export interface CreateWebTerminalResult { @@ -38,6 +37,7 @@ export interface CreateWebTerminalResult { /** Upper bound on replayed scrollback per PTY session (roughly 4 MB). */ const MAX_BUFFER_CHUNKS = 4000; const MAX_BUFFER_BYTES = 4 * 1024 * 1024; +const MAX_UNACKNOWLEDGED_INPUT_BYTES = 256 * 1024; export const MAX_CONCURRENT_WEB_TERMINALS = 8; /** Reclaim a PTY session after this long with no connected listener. */ const IDLE_RECLAIM_MS = 15 * 60 * 1000; @@ -47,16 +47,48 @@ interface PtySession { workspaceCwd: string; buffer: string[]; bufferBytes: number; + unacknowledgedInputBytes: number; exited: boolean; exitCode?: number; outputListeners: Set<(data: string) => void>; exitListeners: Set<(e: { exitCode: number; signal?: number }) => void>; reclaimTimer?: ReturnType; + dataDisposable?: { dispose(): void }; + exitDisposable?: { dispose(): void }; } interface SpawnedWebTerminalPty extends WebTerminalPty { - onData(callback: (data: string) => void): void; - onExit(callback: (e: { exitCode: number; signal?: number }) => void): void; + onData(callback: (data: string) => void): { dispose(): void } | undefined; + onExit( + callback: (e: { exitCode: number; signal?: number }) => void, + ): { dispose(): void } | undefined; +} + +function killPtyTree(pty: WebTerminalPty): void { + if (process.platform === 'win32') { + const taskkill = `${process.env['SystemRoot'] || 'C:\\Windows'}\\System32\\taskkill.exe`; + try { + spawnSync(taskkill, ['/f', '/t', '/pid', String(pty.pid)], { + windowsHide: true, + }); + } catch { + // Fall through to the node-pty host cleanup. + } + } else { + if (pty.pid > 1) { + try { + process.kill(-pty.pid, 'SIGKILL'); + return; + } catch { + // Fall through when the process group has already exited. + } + } + } + try { + pty.kill(); + } catch { + // Already gone. + } } export function resolveWebTerminalShell( @@ -101,7 +133,7 @@ export class WebTerminalRegistry { this.sessions.size + this.creating.size >= MAX_CONCURRENT_WEB_TERMINALS ) { - return { error: 'Web terminal limit reached' }; + return { error: 'Web terminal limit reached', retryable: true }; } this.creating.set(terminalId, options.workspaceCwd); let ptyImpl; @@ -131,11 +163,52 @@ export class WebTerminalRegistry { delete env['npm_config_prefix']; let spawned: SpawnedWebTerminalPty; let proc: WebTerminalPty; + const sessionRef: { current?: PtySession } = {}; + const earlyOutput: string[] = []; + let earlyExit: { exitCode: number; signal?: number } | undefined; + const handleData = (data: string) => { + const session = sessionRef.current; + if (!session) { + earlyOutput.push(data); + return; + } + session.unacknowledgedInputBytes = 0; + if (Buffer.byteLength(data) > MAX_BUFFER_BYTES) { + data = Buffer.from(data).subarray(-MAX_BUFFER_BYTES).toString('utf8'); + while (Buffer.byteLength(data) > MAX_BUFFER_BYTES) data = data.slice(1); + session.buffer = []; + session.bufferBytes = 0; + } + session.buffer.push(data); + session.bufferBytes += Buffer.byteLength(data); + while ( + session.buffer.length > MAX_BUFFER_CHUNKS || + session.bufferBytes > MAX_BUFFER_BYTES + ) { + const dropped = session.buffer.shift(); + if (dropped !== undefined) { + session.bufferBytes -= Buffer.byteLength(dropped); + } + } + for (const listener of session.outputListeners) listener(data); + }; + const handleExit = (e: { exitCode: number; signal?: number }) => { + const session = sessionRef.current; + if (!session) { + earlyExit = e; + return; + } + session.exited = true; + session.exitCode = e.exitCode; + for (const listener of session.exitListeners) listener(e); + }; + let dataDisposable: { dispose(): void } | undefined; + let exitDisposable: { dispose(): void } | undefined; try { spawned = ptyImpl.module.spawn(file, args, { name: 'xterm-256color', - cols: options.cols ?? 80, - rows: options.rows ?? 24, + cols: 80, + rows: 24, cwd: options.workspaceCwd, env: { ...env, @@ -145,6 +218,8 @@ export class WebTerminalRegistry { PROMPT_EOL_MARK: '', }, }) as SpawnedWebTerminalPty; + dataDisposable = spawned.onData(handleData); + exitDisposable = spawned.onExit(handleExit); proc = { pid: spawned.pid, write: (data) => spawned.write(data), @@ -161,33 +236,18 @@ export class WebTerminalRegistry { workspaceCwd: options.workspaceCwd, buffer: [], bufferBytes: 0, + unacknowledgedInputBytes: 0, exited: false, outputListeners: new Set(), exitListeners: new Set(), + dataDisposable, + exitDisposable, }; + sessionRef.current = session; this.sessions.set(terminalId, session); this.finishCreating(terminalId); - - spawned.onData((data: string) => { - session.buffer.push(data); - session.bufferBytes += Buffer.byteLength(data); - while ( - session.buffer.length > MAX_BUFFER_CHUNKS || - session.bufferBytes > MAX_BUFFER_BYTES - ) { - const dropped = session.buffer.shift(); - if (dropped !== undefined) { - session.bufferBytes -= Buffer.byteLength(dropped); - } - } - for (const listener of session.outputListeners) listener(data); - }); - - spawned.onExit((e: { exitCode: number; signal?: number }) => { - session.exited = true; - session.exitCode = e.exitCode; - for (const listener of session.exitListeners) listener(e); - }); + for (const data of earlyOutput) handleData(data); + if (earlyExit) handleExit(earlyExit); this.scheduleReclaim(terminalId, session); return { terminalId }; @@ -202,6 +262,7 @@ export class WebTerminalRegistry { session.outputListeners.add(listener); this.clearReclaim(session); return () => { + if (this.sessions.get(terminalId) !== session) return; session.outputListeners.delete(listener); if (session.outputListeners.size === 0) this.scheduleReclaim(terminalId, session); @@ -233,8 +294,16 @@ export class WebTerminalRegistry { write(terminalId: string, data: string): boolean { const session = this.sessions.get(terminalId); if (!session || session.exited) return false; + const bytes = Buffer.byteLength(data); + if ( + session.unacknowledgedInputBytes + bytes > + MAX_UNACKNOWLEDGED_INPUT_BYTES + ) { + return false; + } try { session.pty.write(data); + session.unacknowledgedInputBytes += bytes; return true; } catch { return false; @@ -277,12 +346,17 @@ export class WebTerminalRegistry { this.clearReclaim(session); this.sessions.delete(terminalId); if (!session.exited) { - try { - session.pty.kill(); - } catch { - // Ignore. + for (const listener of session.exitListeners) { + listener({ exitCode: 143, signal: 15 }); } } + session.dataDisposable?.dispose(); + session.exitDisposable?.dispose(); + session.outputListeners.clear(); + session.exitListeners.clear(); + if (!session.exited) { + killPtyTree(session.pty); + } return true; } @@ -314,7 +388,13 @@ export class WebTerminalRegistry { private scheduleReclaim(terminalId: string, session: PtySession): void { if (session.reclaimTimer) clearTimeout(session.reclaimTimer); session.reclaimTimer = setTimeout(() => { - if (session.outputListeners.size === 0) this.release(terminalId); + if ( + !this.disposed && + this.sessions.get(terminalId) === session && + session.outputListeners.size === 0 + ) { + this.release(terminalId); + } }, IDLE_RECLAIM_MS); session.reclaimTimer.unref?.(); } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index ea1f8c935da..48314164f2a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -3077,6 +3077,11 @@ export function App({ .keys() .next().value; if (oldestSessionId) { + const oldestState = + artifactPanelStateBySessionRef.current.get(oldestSessionId); + for (const tab of oldestState?.tabs ?? []) { + if (tab.kind === 'terminal') releaseWebTerminal(tab.id); + } artifactPanelStateBySessionRef.current.delete(oldestSessionId); } } diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index f8cc1aef948..043029cb558 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -597,6 +597,7 @@ export function ArtifactPanel({
))} diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx index 5dfb7460ebd..f48fbff56a9 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx @@ -29,12 +29,24 @@ vi.mock('@xterm/addon-fit', () => ({ })); vi.mock('../../themeContext', () => ({ useTheme: () => 'light' })); vi.mock('../../config/daemon', () => ({ getDaemonToken: () => '' })); +vi.mock('../../i18n', () => ({ + useI18n: () => ({ + t: (key: string, values?: Record) => + ({ + 'terminal.notice.exited': `Process exited with code ${values?.['exitCode'] ?? '?'}`, + 'terminal.notice.error': `Error: ${values?.['message'] ?? ''}`, + 'terminal.notice.unknownError': 'Unknown error', + 'terminal.notice.reconnecting': 'Connection lost — reconnecting…', + })[key] ?? key, + }), +})); import { releaseWebTerminal, TerminalPanel } from './TerminalPanel'; class FakeWebSocket { static readonly CONNECTING = 0; static readonly OPEN = 1; + static readonly CLOSING = 2; static readonly CLOSED = 3; static readonly instances: FakeWebSocket[] = []; @@ -88,7 +100,7 @@ describe('TerminalPanel', () => { }, ); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); + callback(16); return 1; }); container = document.createElement('div'); @@ -103,9 +115,15 @@ describe('TerminalPanel', () => { vi.unstubAllGlobals(); }); - function render(): FakeWebSocket { + function render(active = true): FakeWebSocket { act(() => { - root.render(); + root.render( + , + ); }); return FakeWebSocket.instances[0]!; } @@ -139,7 +157,7 @@ describe('TerminalPanel', () => { ); }); - it.each([4000, 4001, 4002, 4003, 4004])( + it.each([4000, 4001, 4002, 4004])( 'does not reconnect after non-retryable close code %s', async (code) => { const ws = render(); @@ -185,4 +203,77 @@ describe('TerminalPanel', () => { expect(ws.send).toHaveBeenCalledWith('\x00{"type":"release"}'); }); + + it('releases an exited session through a release handshake', async () => { + const ws = render(); + act(() => ws.open()); + act(() => ws.closeWith(4000)); + + act(() => releaseWebTerminal('terminal:one')); + + const releaseSocket = FakeWebSocket.instances[1]!; + expect(new URL(releaseSocket.url).searchParams.get('release')).toBe('1'); + act(() => releaseSocket.open()); + expect(releaseSocket.send).toHaveBeenCalledWith('\x00{"type":"release"}'); + act(() => releaseSocket.closeWith(4004)); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(FakeWebSocket.instances).toHaveLength(2); + }); + + it('retries a failed release handshake a bounded number of times', async () => { + const ws = render(); + act(() => ws.open()); + act(() => ws.closeWith(4000)); + act(() => releaseWebTerminal('terminal:one')); + + act(() => FakeWebSocket.instances[1]!.closeWith(1006)); + await act(async () => vi.advanceTimersByTimeAsync(500)); + + expect(FakeWebSocket.instances).toHaveLength(3); + expect( + new URL(FakeWebSocket.instances[2]!.url).searchParams.get('release'), + ).toBe('1'); + }); + + it('sends the connection contract and initial terminal size', () => { + const ws = render(); + + expect(ws.binaryType).toBe('arraybuffer'); + const url = new URL(ws.url); + expect(url.pathname).toBe('/terminal'); + expect(url.searchParams.get('terminalId')).toBe('terminal:one'); + expect(url.searchParams.get('cwd')).toBe('/workspace'); + + act(() => ws.open()); + expect(ws.send).toHaveBeenCalledWith( + '\x00{"type":"resize","cols":80,"rows":24}', + ); + }); + + it('does not reconnect after an exit control frame', async () => { + const ws = render(); + act(() => ws.open()); + + act(() => ws.message('\x00{"type":"exit","exitCode":0}')); + act(() => ws.closeWith(1006)); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + + expect(terminal.writeln).toHaveBeenCalledWith( + expect.stringContaining('Process exited with code 0'), + ); + expect(FakeWebSocket.instances).toHaveLength(1); + }); + + it('restores focus when its terminal tab becomes active', () => { + render(false); + terminal.focus.mockClear(); + + act(() => { + root.render( + , + ); + }); + + expect(terminal.focus).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.tsx index a8868cf3301..2dcbb063df0 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.tsx @@ -10,18 +10,20 @@ import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { useTheme, type WebShellTheme } from '../../themeContext'; import { getDaemonToken } from '../../config/daemon'; +import { useI18n } from '../../i18n'; interface TerminalPanelProps { /** Stable id shared with the backend PTY session; reconnects reuse it. */ terminalId: string; cwd?: string; + active?: boolean; } const CONTROL_FRAME_PREFIX = '\x00'; /** Exponential backoff bounds for automatic WebSocket reconnection. */ const RECONNECT_INITIAL_MS = 500; const RECONNECT_MAX_MS = 10_000; -const NON_RETRYABLE_CLOSE_CODES = new Set([4000, 4001, 4002, 4003, 4004]); +const NON_RETRYABLE_CLOSE_CODES = new Set([4000, 4001, 4002, 4004]); const releaseCallbacks = new Map void>(); export function releaseWebTerminal(terminalId: string): void { @@ -52,12 +54,17 @@ function wsProtocols(): string[] { return token ? [WS_AUTH_SUBPROTOCOL, bearerSubprotocol(token)] : []; } -function buildWsUrl(terminalId: string, cwd: string | undefined): string { +function buildWsUrl( + terminalId: string, + cwd: string | undefined, + release = false, +): string { const isSecure = window.location.protocol === 'https:'; const base = `${isSecure ? 'wss:' : 'ws:'}//${window.location.host}`; const url = new URL('/terminal', base); url.searchParams.set('terminalId', terminalId); if (cwd) url.searchParams.set('cwd', cwd); + if (release) url.searchParams.set('release', '1'); return url.toString(); } @@ -75,8 +82,13 @@ function xtermTheme(theme: WebShellTheme) { }; } -export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { +export function TerminalPanel({ + terminalId, + cwd, + active = true, +}: TerminalPanelProps) { const theme = useTheme(); + const { t } = useI18n(); const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); @@ -95,7 +107,7 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { const term = new Terminal({ cursorBlink: true, fontSize: 13, - fontFamily: 'var(--font-mono, Menlo, Monaco, "Courier New", monospace)', + fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: xtermTheme(theme), }); const fit = new FitAddon(); @@ -104,16 +116,32 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { termRef.current = term; fitRef.current = fit; + const sendCurrentResize = () => { + const ws = wsRef.current; + if (ws?.readyState !== WebSocket.OPEN) return; + ws.send( + CONTROL_FRAME_PREFIX + + JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }), + ); + }; + requestAnimationFrame(() => { try { fit.fit(); + sendCurrentResize(); } catch { - // Container may not be laid out yet — retry on next frame. - requestAnimationFrame(() => fit.fit()); + requestAnimationFrame(() => { + if (termRef.current !== term) return; + try { + fit.fit(); + sendCurrentResize(); + } catch { + // The panel may have been removed before the retry. + } + }); } }); - const wsUrl = buildWsUrl(terminalId, cwd); const protocols = wsProtocols(); let disposed = false; @@ -122,6 +150,8 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { let lostNoticeWritten = false; let resizeTimeout: ReturnType | undefined; let releaseRequested = false; + let releaseAttempts = 0; + let releaseRetryTimer: ReturnType | undefined; let ended = false; function handleControl(raw: string): boolean { @@ -136,13 +166,17 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { const exitCode = typeof ctrl.exitCode === 'number' ? String(ctrl.exitCode) : '?'; term.writeln( - `\r\n\x1b[33m[Process exited with code ${exitCode}]\x1b[0m`, + `\r\n\x1b[33m[${t('terminal.notice.exited', { exitCode })}]\x1b[0m`, ); return true; } else if (ctrl.type === 'error') { const message = - typeof ctrl.message === 'string' ? ctrl.message : 'Unknown error'; - term.writeln(`\r\n\x1b[31m[Error: ${message}]\x1b[0m`); + typeof ctrl.message === 'string' + ? ctrl.message + : t('terminal.notice.unknownError'); + term.writeln( + `\r\n\x1b[31m[${t('terminal.notice.error', { message })}]\x1b[0m`, + ); return true; } } catch { @@ -158,6 +192,7 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { } function handleMessage(event: MessageEvent) { + if (disposed) return; if (typeof event.data === 'string') { writeMessage(event.data); } else { @@ -165,10 +200,11 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { } } - function connect() { - if (disposed) return; + function connect(releaseOnly = false) { + if (disposed && !releaseOnly) return; + if (releaseOnly) releaseAttempts += 1; const ws = new WebSocket( - wsUrl, + buildWsUrl(terminalId, cwd, releaseOnly), protocols.length > 0 ? protocols : undefined, ); ws.binaryType = 'arraybuffer'; @@ -201,17 +237,28 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { } }; - ws.onmessage = handleMessage; + ws.onmessage = releaseOnly ? null : handleMessage; ws.onerror = () => { // Errors surface through close; reconnect is handled there. }; ws.onclose = (event) => { + if (releaseOnly) { + if (event.code !== 4004 && releaseAttempts < 3) { + releaseRetryTimer = setTimeout( + () => connect(true), + RECONNECT_INITIAL_MS, + ); + } + return; + } if (disposed || releaseRequested) return; if (ended || NON_RETRYABLE_CLOSE_CODES.has(event.code)) return; if (!lostNoticeWritten) { - term.writeln('\r\n\x1b[33m[Connection lost — reconnecting…]\x1b[0m'); + term.writeln( + `\r\n\x1b[33m[${t('terminal.notice.reconnecting')}]\x1b[0m`, + ); lostNoticeWritten = true; } reconnectTimer = setTimeout(connect, reconnectDelay); @@ -219,7 +266,7 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { }; } - connect(); + const connectFrame = requestAnimationFrame(() => connect()); const release = () => { if (releaseRequested) return; @@ -229,8 +276,8 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { if (ws?.readyState === WebSocket.OPEN) { ws.send(CONTROL_FRAME_PREFIX + JSON.stringify({ type: 'release' })); ws.close(); - } else if (!ws || ws.readyState === WebSocket.CLOSED) { - connect(); + } else if (!ws || ws.readyState !== WebSocket.CONNECTING) { + connect(true); } }; releaseCallbacks.set(terminalId, release); @@ -250,17 +297,7 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { resizeTimeout = setTimeout(() => { try { fitRef.current?.fit(); - const ws = wsRef.current; - const current = termRef.current; - if (!current || !ws || ws.readyState !== WebSocket.OPEN) return; - ws.send( - CONTROL_FRAME_PREFIX + - JSON.stringify({ - type: 'resize', - cols: current.cols, - rows: current.rows, - }), - ); + sendCurrentResize(); } catch { // Fit can throw if the terminal was disposed. } @@ -276,6 +313,8 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { releaseCallbacks.delete(terminalId); } if (reconnectTimer) clearTimeout(reconnectTimer); + if (releaseRetryTimer) clearTimeout(releaseRetryTimer); + cancelAnimationFrame(connectFrame); clearTimeout(resizeTimeout); resizeObserver.disconnect(); disposable.dispose(); @@ -293,6 +332,10 @@ export function TerminalPanel({ terminalId, cwd }: TerminalPanelProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (active) termRef.current?.focus(); + }, [active]); + return (
+ `Process exited with code ${v?.exitCode ?? '?'}`, + 'terminal.notice.error': (v) => `Error: ${v?.message ?? ''}`, + 'terminal.notice.unknownError': 'Unknown error', + 'terminal.notice.reconnecting': 'Connection lost — reconnecting…', 'rightPanel.add': 'Add panel', 'attachment.showPreview': 'Preview', 'attachment.showSource': 'Source', @@ -4249,6 +4254,10 @@ const ZH: Messages = { 'sideTask.create': '新建侧边任务', 'terminal.title': '终端', 'terminal.open': '打开终端', + 'terminal.notice.exited': (v) => `进程已退出,退出码 ${v?.exitCode ?? '?'}`, + 'terminal.notice.error': (v) => `错误:${v?.message ?? ''}`, + 'terminal.notice.unknownError': '未知错误', + 'terminal.notice.reconnecting': '连接已断开,正在重连…', 'rightPanel.add': '添加页签', 'attachment.showPreview': '预览', 'attachment.showSource': '源码', From 36f7c4300ac5de94ffc90f58ef20fd371b3943c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 26 Aug 2026 09:34:48 +0800 Subject: [PATCH 06/11] docs(serve): document web terminal capability --- docs/developers/qwen-serve-protocol.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index d786d3ae7b8..d037b5cf3f5 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -512,6 +512,7 @@ operator diagnostic snapshot documented below. | `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. | | `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. | | `realtime_voice` | the macOS WebShell daemon has Live Voice enabled and native Host integration active. `/live/status` reports readiness, but the capability is withdrawn until the feature is enabled. | +| `web_terminal` | ACP HTTP is enabled, so the authenticated Web Terminal endpoint is available. | From cc54771a9602cbc3d19cc795b152f53d152dfc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Wed, 26 Aug 2026 20:27:34 +0800 Subject: [PATCH 07/11] fix(web-shell): address terminal review findings --- .../cli/src/serve/routes/terminal.test.ts | 69 ++++++++++++++++--- packages/cli/src/serve/routes/terminal.ts | 19 +++-- packages/cli/src/serve/run-qwen-serve.ts | 22 +++--- .../services/web-terminal-registry.test.ts | 28 ++++++-- .../src/services/web-terminal-registry.ts | 20 +++--- packages/web-shell/client/App.test.tsx | 23 +++++++ packages/web-shell/client/App.tsx | 24 ++++++- .../artifacts/ArtifactPanel.module.css | 5 +- .../terminal/TerminalPanel.test.tsx | 51 +++++++++++++- .../components/terminal/TerminalPanel.tsx | 44 ++++++++++-- 10 files changed, 253 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts index a019fc63359..51a924f4b92 100644 --- a/packages/cli/src/serve/routes/terminal.test.ts +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -52,7 +52,7 @@ function registryWithSnapshot( addOutputListener: vi.fn(() => vi.fn()), addExitListener: vi.fn(() => vi.fn()), resize: vi.fn(() => true), - write: vi.fn(() => true), + write: vi.fn(() => 'written'), release: vi.fn(), releaseWorkspace: vi.fn(), } as unknown as WebTerminalRegistry; @@ -185,6 +185,10 @@ describe('terminal WebSocket route', () => { expect(sentOutput(ws)).toEqual(['done\r\n']); expect(ws.sent).toContain('\x00{"type":"exit","exitCode":7}'); expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); expect(registry.write).not.toHaveBeenCalled(); }); @@ -237,6 +241,10 @@ describe('terminal WebSocket route', () => { expect(ws.sent).toContain('\x00{"type":"exit","exitCode":9}'); expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + expect(registry.release).toHaveBeenCalledWith( + 'terminal:manual-1', + '/workspace', + ); }); it('closes an active WebSocket when its workspace is released', async () => { @@ -443,13 +451,13 @@ describe('terminal WebSocket route', () => { expect(registry.resize).toHaveBeenCalledWith('terminal:manual-1', 120, 40); }); - it('closes and releases a session when PTY input backpressure is hit', async () => { + it('reconnects without releasing on PTY input backpressure', async () => { const registry = registryWithSnapshot({ output: '', exited: false, workspaceCwd: '/workspace', }); - vi.mocked(registry.write).mockReturnValueOnce(false); + vi.mocked(registry.write).mockReturnValueOnce('backpressure'); const ws = new FakeWebSocket(); await createTerminalWsHandler(registry, resolveWorkspace).onConnection( ws as unknown as WebSocket, @@ -458,13 +466,58 @@ describe('terminal WebSocket route', () => { ws.emit('message', Buffer.from('large paste'), true); - expect(registry.release).toHaveBeenCalledWith( - 'terminal:manual-1', - '/workspace', - ); + expect(registry.release).not.toHaveBeenCalled(); expect(ws.close).toHaveBeenCalledWith(1013, 'Terminal input backpressure'); }); + it('closes without retry when the terminal session is unavailable', async () => { + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.write).mockReturnValueOnce('unavailable'); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + + ws.emit('message', Buffer.from('input'), true); + + expect(registry.release).not.toHaveBeenCalled(); + expect(ws.close).toHaveBeenCalledWith(4002, 'Session unavailable'); + }); + + it('allows live output while a maximum-size replay is buffered', async () => { + let output: ((data: string) => void) | undefined; + const registry = registryWithSnapshot({ + output: 'x'.repeat(4 * 1024 * 1024), + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addOutputListener).mockImplementationOnce( + (_terminalId, listener) => { + output = listener; + return vi.fn(); + }, + ); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, resolveWorkspace).onConnection( + ws as unknown as WebSocket, + request, + ); + ws.bufferedAmount = 4 * 1024 * 1024; + + output?.('live'); + + expect(ws.close).not.toHaveBeenCalledWith( + 1013, + 'Terminal output backpressure', + ); + expect(sentOutput(ws)).toContain('live'); + }); + it('detaches and closes a stalled output connection', async () => { let output: ((data: string) => void) | undefined; const detachOutput = vi.fn(); @@ -484,7 +537,7 @@ describe('terminal WebSocket route', () => { ws as unknown as WebSocket, request, ); - ws.bufferedAmount = 1024 * 1024 + 1; + ws.bufferedAmount = 8 * 1024 * 1024 + 1; output?.('more output'); diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts index 5a870354c09..061e46611e7 100644 --- a/packages/cli/src/serve/routes/terminal.ts +++ b/packages/cli/src/serve/routes/terminal.ts @@ -13,7 +13,7 @@ const TERMINAL_WS_PATH = '/terminal'; const CONTROL_FRAME_PREFIX = '\x00'; const MAX_PENDING_INPUT_BYTES = 64 * 1024; const MAX_TERMINAL_DIMENSION = 1000; -const MAX_SOCKET_BUFFERED_BYTES = 1024 * 1024; +const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; export interface WebTerminalWorkspaceContext { workspaceCwd: string; @@ -205,6 +205,7 @@ export function createTerminalWsHandler( const finishExited = (exitCode: number | undefined) => { sendControl(ws, { type: 'exit', exitCode }); cleanup(); + registry.release(terminalId, workspace.workspaceCwd); ws.close(4000, 'Terminal exited'); }; const onMessage = (data: unknown, isBinary = false) => { @@ -245,14 +246,22 @@ export function createTerminalWsHandler( ws.close(4002, 'Terminal workspace unavailable'); return; } - if (!registry.write(terminalId, text)) { + const writeResult = registry.write(terminalId, text); + if (writeResult !== 'written') { sendControl(ws, { type: 'error', - message: 'Terminal input backpressure', + message: + writeResult === 'backpressure' + ? 'Terminal input backpressure' + : 'Session unavailable', }); cleanup(); - registry.release(terminalId, workspace.workspaceCwd); - ws.close(1013, 'Terminal input backpressure'); + ws.close( + writeResult === 'backpressure' ? 1013 : 4002, + writeResult === 'backpressure' + ? 'Terminal input backpressure' + : 'Session unavailable', + ); } }; diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 14a1ea98eb9..b875ce3762c 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -6589,9 +6589,6 @@ async function runQwenServeImpl( const stopScheduledTaskKeepaliveForWorkspace = app?.locals?.[ 'stopScheduledTaskKeepaliveForWorkspace' ] as ((workspaceCwd: string) => void) | undefined; - const releaseWebTerminalsForWorkspace = app?.locals?.[ - 'releaseWebTerminalsForWorkspace' - ] as ((workspaceCwd: string) => void) | undefined; try { stopScheduledTaskKeepaliveForWorkspace?.(runtimeToDrain.workspaceCwd); } catch (err) { @@ -6600,14 +6597,6 @@ async function runQwenServeImpl( err instanceof Error ? err : null, ); } - try { - releaseWebTerminalsForWorkspace?.(runtimeToDrain.workspaceCwd); - } catch (err) { - daemonLog.error( - 'workspace web-terminal drain error', - err instanceof Error ? err : null, - ); - } }, cancelDrain(runtimeToDrain: WorkspaceRuntime): void { if (runtimeToDrain.primary && bridgeRef === undefined) { @@ -6752,6 +6741,9 @@ async function runQwenServeImpl( const stopScheduledTaskKeepaliveForWorkspace = app?.locals?.[ 'stopScheduledTaskKeepaliveForWorkspace' ] as ((workspaceCwd: string) => void) | undefined; + const releaseWebTerminalsForWorkspace = app?.locals?.[ + 'releaseWebTerminalsForWorkspace' + ] as ((workspaceCwd: string) => void) | undefined; try { stopWorkspaceGitStateForWorkspace?.(runtimeToDrain.workspaceCwd); } catch (err) { @@ -6770,6 +6762,14 @@ async function runQwenServeImpl( err instanceof Error ? err : null, ); } + try { + releaseWebTerminalsForWorkspace?.(runtimeToDrain.workspaceCwd); + } catch (err) { + daemonLog.error( + 'workspace web-terminal cleanup error', + err instanceof Error ? err : null, + ); + } } let bridgeStopped = false; try { diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts index 3e30ac01ecd..1022c4ca626 100644 --- a/packages/core/src/services/web-terminal-registry.test.ts +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -206,7 +206,7 @@ describe('WebTerminalRegistry', () => { exitCode: 7, workspaceCwd: '/workspace', }); - expect(registry.write(terminalId, 'ignored')).toBe(false); + expect(registry.write(terminalId, 'ignored')).toBe('unavailable'); expect(registry.resize(terminalId, 80, 24)).toBe(false); }); @@ -248,15 +248,35 @@ describe('WebTerminalRegistry', () => { const listener = vi.fn(); registry.addOutputListener('terminal:io', listener); - expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe(true); - expect(registry.write('terminal:io', 'x')).toBe(false); + expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe( + 'written', + ); + expect(registry.write('terminal:io', 'x')).toBe('backpressure'); expect(write).toHaveBeenCalledOnce(); expect(registry.resize('terminal:io', 120, 40)).toBe(true); expect(resize).toHaveBeenCalledWith(120, 40); onData('ready'); expect(listener).toHaveBeenCalledWith('ready'); - expect(registry.write('terminal:io', 'x')).toBe(true); + expect(registry.write('terminal:io', 'x')).toBe('written'); + }); + + it('does not count exited sessions against the live terminal limit', async () => { + const registry = new WebTerminalRegistry(); + for (let i = 0; i < MAX_CONCURRENT_WEB_TERMINALS; i++) { + await registry.create({ + terminalId: `terminal:exited-${i}`, + workspaceCwd: '/workspace', + }); + onExit({ exitCode: 0 }); + } + + await expect( + registry.create({ + terminalId: 'terminal:replacement', + workspaceCwd: '/workspace', + }), + ).resolves.toEqual({ terminalId: 'terminal:replacement' }); }); it('preserves a clean exit code in snapshots', async () => { diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts index 3739daf27fe..3735db4ba2f 100644 --- a/packages/core/src/services/web-terminal-registry.ts +++ b/packages/core/src/services/web-terminal-registry.ts @@ -34,6 +34,8 @@ export interface CreateWebTerminalResult { terminalId: string; } +export type WebTerminalWriteResult = 'written' | 'backpressure' | 'unavailable'; + /** Upper bound on replayed scrollback per PTY session (roughly 4 MB). */ const MAX_BUFFER_CHUNKS = 4000; const MAX_BUFFER_BYTES = 4 * 1024 * 1024; @@ -129,10 +131,10 @@ export class WebTerminalRegistry { retryable: true, }; } - if ( - this.sessions.size + this.creating.size >= - MAX_CONCURRENT_WEB_TERMINALS - ) { + const liveSessions = [...this.sessions.values()].filter( + (session) => !session.exited, + ).length; + if (liveSessions + this.creating.size >= MAX_CONCURRENT_WEB_TERMINALS) { return { error: 'Web terminal limit reached', retryable: true }; } this.creating.set(terminalId, options.workspaceCwd); @@ -291,22 +293,22 @@ export class WebTerminalRegistry { }; } - write(terminalId: string, data: string): boolean { + write(terminalId: string, data: string): WebTerminalWriteResult { const session = this.sessions.get(terminalId); - if (!session || session.exited) return false; + if (!session || session.exited) return 'unavailable'; const bytes = Buffer.byteLength(data); if ( session.unacknowledgedInputBytes + bytes > MAX_UNACKNOWLEDGED_INPUT_BYTES ) { - return false; + return 'backpressure'; } try { session.pty.write(data); session.unacknowledgedInputBytes += bytes; - return true; + return 'written'; } catch { - return false; + return 'unavailable'; } } diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 0eef2cb8d5b..ebd13d459cf 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -5301,6 +5301,29 @@ describe('App global shortcuts', () => { expect.anything(), ); }); + + it('leaves terminal key chords to xterm', async () => { + renderApp(); + const terminal = document.createElement('div'); + terminal.dataset['webTerminal'] = ''; + const input = document.createElement('textarea'); + terminal.append(input); + document.body.append(terminal); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ctrlKey: true, + key: 'o', + }); + + await act(async () => { + input.dispatchEvent(event); + await Promise.resolve(); + }); + + expect(event.defaultPrevented).toBe(false); + terminal.remove(); + }); }); describe('App plan todos', () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index bcfbc10cdcc..36f364ff9e0 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -359,6 +359,13 @@ const MIN_CHAT_PANE_WIDTH_WITH_ARTIFACT_PANEL = 500; const SUBAGENT_PANEL_ANIMATION_FALLBACK_MS = 700; const MIN_DOCKED_MESSAGE_AREA_WIDTH = 800; const DOCKED_ENVIRONMENT_PANEL_WIDTH = 332; + +function isWebTerminalTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && target.closest('[data-web-terminal]') !== null + ); +} + // The docked fullscreen surface contains Tab itself instead of going through // Radix FocusScope: FocusScope registers every mounted scope in a // module-global stack and pauses the current head even with trapped={false}, @@ -7079,7 +7086,12 @@ export function App({ useEffect(() => { const onBtwShortcut = (e: KeyboardEvent) => { - if (interactionBlocked || pendingApproval) return; + if ( + interactionBlocked || + pendingApproval || + isWebTerminalTarget(e.target) + ) + return; const message = btwMessage; if (!message || message.role !== 'btw') return; @@ -10867,7 +10879,7 @@ export function App({ useEffect(() => { const onGlobalShortcut = (e: KeyboardEvent) => { - if (interactionBlocked) return; + if (interactionBlocked || isWebTerminalTarget(e.target)) return; if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { if (e.key === 'l') { e.preventDefault(); @@ -10954,7 +10966,13 @@ export function App({ const onKeyDown = (e: KeyboardEvent) => { // keyCode 229: WebKit marks IME-owned keys this way while isComposing // is still false; the IME owns them exactly like composing keys. - if (e.defaultPrevented || e.isComposing || e.keyCode === 229) return; + if ( + e.defaultPrevented || + e.isComposing || + e.keyCode === 229 || + isWebTerminalTarget(e.target) + ) + return; const live = escLiveRef.current; // The fullscreen right panel (artifacts/subagents) is the topmost diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css index 94b14a423f3..a50ec310777 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.module.css @@ -236,10 +236,7 @@ } .terminalPaneHidden { - position: absolute; - inset: 12px; - visibility: hidden; - pointer-events: none; + display: none; } .attachmentPreviewButton { diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx index f48fbff56a9..7608844f1c8 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.test.tsx @@ -9,6 +9,8 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +const workspace = vi.hoisted(() => ({ baseUrl: 'http://localhost' })); +const fit = vi.hoisted(() => vi.fn()); const terminal = vi.hoisted(() => ({ options: {} as Record, cols: 80, @@ -19,16 +21,21 @@ const terminal = vi.hoisted(() => ({ write: vi.fn(), writeln: vi.fn(), focus: vi.fn(), + blur: vi.fn(), + refresh: vi.fn(), dispose: vi.fn(), onData: vi.fn((_listener: (data: string) => void) => ({ dispose: vi.fn() })), })); vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(() => terminal) })); vi.mock('@xterm/addon-fit', () => ({ - FitAddon: vi.fn(() => ({ fit: vi.fn() })), + FitAddon: vi.fn(() => ({ fit })), })); vi.mock('../../themeContext', () => ({ useTheme: () => 'light' })); vi.mock('../../config/daemon', () => ({ getDaemonToken: () => '' })); +vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ + useWorkspace: () => workspace, +})); vi.mock('../../i18n', () => ({ useI18n: () => ({ t: (key: string, values?: Record) => @@ -90,6 +97,7 @@ describe('TerminalPanel', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + workspace.baseUrl = 'http://localhost'; FakeWebSocket.instances.length = 0; vi.stubGlobal('WebSocket', FakeWebSocket); vi.stubGlobal( @@ -250,6 +258,15 @@ describe('TerminalPanel', () => { ); }); + it('uses the configured daemon origin and base path', () => { + workspace.baseUrl = 'https://daemon.example/qwen/'; + const ws = render(); + const url = new URL(ws.url); + + expect(url.origin).toBe('wss://daemon.example'); + expect(url.pathname).toBe('/qwen/terminal'); + }); + it('does not reconnect after an exit control frame', async () => { const ws = render(); act(() => ws.open()); @@ -265,8 +282,12 @@ describe('TerminalPanel', () => { }); it('restores focus when its terminal tab becomes active', () => { - render(false); + const ws = render(false); + act(() => ws.open()); terminal.focus.mockClear(); + terminal.refresh.mockClear(); + fit.mockClear(); + ws.send.mockClear(); act(() => { root.render( @@ -275,5 +296,31 @@ describe('TerminalPanel', () => { }); expect(terminal.focus).toHaveBeenCalledOnce(); + expect(terminal.refresh).toHaveBeenCalledWith(0, 23); + expect(fit).toHaveBeenCalledOnce(); + expect(ws.send).toHaveBeenCalledWith( + '\x00{"type":"resize","cols":80,"rows":24}', + ); + }); + + it('blurs and suppresses input while its terminal tab is inactive', () => { + const ws = render(); + act(() => ws.open()); + const handleInput = terminal.onData.mock.calls.at(-1)?.[0]; + + act(() => { + root.render( + , + ); + }); + ws.send.mockClear(); + act(() => handleInput?.('hidden input')); + + expect(terminal.blur).toHaveBeenCalledOnce(); + expect(ws.send).not.toHaveBeenCalled(); }); }); diff --git a/packages/web-shell/client/components/terminal/TerminalPanel.tsx b/packages/web-shell/client/components/terminal/TerminalPanel.tsx index 2dcbb063df0..2853ab89a39 100644 --- a/packages/web-shell/client/components/terminal/TerminalPanel.tsx +++ b/packages/web-shell/client/components/terminal/TerminalPanel.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; +import { useWorkspace } from '@qwen-code/webui/daemon-react-sdk'; import '@xterm/xterm/css/xterm.css'; import { useTheme, type WebShellTheme } from '../../themeContext'; import { getDaemonToken } from '../../config/daemon'; @@ -55,13 +56,17 @@ function wsProtocols(): string[] { } function buildWsUrl( + baseUrl: string, terminalId: string, cwd: string | undefined, release = false, ): string { - const isSecure = window.location.protocol === 'https:'; - const base = `${isSecure ? 'wss:' : 'ws:'}//${window.location.host}`; - const url = new URL('/terminal', base); + const base = new URL(baseUrl); + const url = new URL( + 'terminal', + `${base.origin}${base.pathname.replace(/\/?$/, '/')}`, + ); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; url.searchParams.set('terminalId', terminalId); if (cwd) url.searchParams.set('cwd', cwd); if (release) url.searchParams.set('release', '1'); @@ -88,11 +93,14 @@ export function TerminalPanel({ active = true, }: TerminalPanelProps) { const theme = useTheme(); + const { baseUrl } = useWorkspace(); const { t } = useI18n(); const containerRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); + const activeRef = useRef(active); + activeRef.current = active; // Follow web-shell theme switches without rebuilding the terminal or // dropping the WebSocket connection. @@ -204,7 +212,7 @@ export function TerminalPanel({ if (disposed && !releaseOnly) return; if (releaseOnly) releaseAttempts += 1; const ws = new WebSocket( - buildWsUrl(terminalId, cwd, releaseOnly), + buildWsUrl(baseUrl, terminalId, cwd, releaseOnly), protocols.length > 0 ? protocols : undefined, ); ws.binaryType = 'arraybuffer'; @@ -284,6 +292,7 @@ export function TerminalPanel({ // xterm → WebSocket (raw keystrokes = stdin, control = resize) const disposable = term.onData((data: string) => { + if (!activeRef.current) return; const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(new TextEncoder().encode(data)); @@ -305,7 +314,7 @@ export function TerminalPanel({ }); resizeObserver.observe(containerRef.current); - term.focus(); + if (activeRef.current) term.focus(); return () => { disposed = true; @@ -333,11 +342,34 @@ export function TerminalPanel({ }, []); useEffect(() => { - if (active) termRef.current?.focus(); + const term = termRef.current; + if (active) { + try { + fitRef.current?.fit(); + if (term) term.refresh(0, term.rows - 1); + const ws = wsRef.current; + if (term && ws?.readyState === WebSocket.OPEN) { + ws.send( + CONTROL_FRAME_PREFIX + + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + }), + ); + } + } catch { + // The panel may be changing visibility while it unmounts. + } + term?.focus(); + } else { + term?.blur(); + } }, [active]); return (
Date: Thu, 27 Aug 2026 16:05:21 +0800 Subject: [PATCH 08/11] fix(web-shell): preserve terminal sessions and key events --- .../cli/src/serve/routes/terminal.test.ts | 12 ++- packages/cli/src/serve/routes/terminal.ts | 2 - .../services/web-terminal-registry.test.ts | 17 ++++ .../src/services/web-terminal-registry.ts | 2 +- packages/web-shell/client/App.test.tsx | 93 ++++++++++++++++++- packages/web-shell/client/App.tsx | 6 ++ 6 files changed, 122 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts index d9fd2822c90..f8e253fb8da 100644 --- a/packages/cli/src/serve/routes/terminal.test.ts +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -678,16 +678,20 @@ describe('terminal WebSocket route', () => { expect(ws.sent).toContain( '\x00{"type":"error","message":"Terminal workspace unavailable"}', ); - expect(registry.release).toHaveBeenCalledWith( - 'terminal:manual-1', - '/workspace', - ); + expect(registry.release).not.toHaveBeenCalled(); expect(ws.close).toHaveBeenCalledWith( 4002, 'Terminal workspace unavailable', ); expect(registry.write).not.toHaveBeenCalled(); expect(registry.resize).not.toHaveBeenCalled(); + + available = true; + await createTerminalWsHandler(registry, () => context).onConnection( + new FakeWebSocket() as unknown as WebSocket, + request, + ); + expect(registry.addOutputListener).toHaveBeenCalledTimes(2); }, ); diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts index b60903b4817..2dfd6ccb9eb 100644 --- a/packages/cli/src/serve/routes/terminal.ts +++ b/packages/cli/src/serve/routes/terminal.ts @@ -263,7 +263,6 @@ export function createTerminalWsHandler( message: 'Terminal workspace unavailable', }); cleanup(); - registry.release(terminalId, workspace.workspaceCwd); ws.close(4002, 'Terminal workspace unavailable'); return; } @@ -277,7 +276,6 @@ export function createTerminalWsHandler( message: 'Terminal workspace unavailable', }); cleanup(); - registry.release(terminalId, workspace.workspaceCwd); ws.close(4002, 'Terminal workspace unavailable'); return; } diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts index d284b5698a2..07fcf78af90 100644 --- a/packages/core/src/services/web-terminal-registry.test.ts +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -302,6 +302,23 @@ describe('WebTerminalRegistry', () => { }); }); + it('notifies every exit listener when one releases the session', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:exit-listeners', + workspaceCwd: '/workspace', + }); + const first = vi.fn(() => registry.release('terminal:exit-listeners')); + const second = vi.fn(); + registry.addExitListener('terminal:exit-listeners', first); + registry.addExitListener('terminal:exit-listeners', second); + + onExit({ exitCode: 7 }); + + expect(first).toHaveBeenCalledWith({ exitCode: 7 }); + expect(second).toHaveBeenCalledWith({ exitCode: 7 }); + }); + it('does not spawn a PTY after disposal wins an in-flight create', async () => { let resolvePty: ((value: unknown) => void) | undefined; getPty.mockReturnValueOnce( diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts index dba981545cd..922d8e82cf5 100644 --- a/packages/core/src/services/web-terminal-registry.ts +++ b/packages/core/src/services/web-terminal-registry.ts @@ -202,7 +202,7 @@ export class WebTerminalRegistry { } session.exited = true; session.exitCode = e.exitCode; - for (const listener of session.exitListeners) listener(e); + for (const listener of [...session.exitListeners]) listener(e); }; let dataDisposable: { dispose(): void } | undefined; let exitDisposable: { dispose(): void } | undefined; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8bcf56fb63d..f6b39c5f39f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1784,6 +1784,7 @@ vi.doMock('./components/terminal/TerminalPanel', async () => { TerminalPanel: (props: { terminalId: string }) => React.createElement('div', { 'data-testid': 'terminal-panel', + 'data-web-terminal': '', 'data-terminal-id': props.terminalId, }), }; @@ -2753,6 +2754,54 @@ describe('artifact panel fullscreen', () => { expect(restoredContent?.className).toContain('min(520px'); }); + it('leaves terminal Escape to xterm in the floating drawer', async () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + }); + mockConnection.capabilities.features = ['web_terminal']; + const { container } = renderApp({ + rightPanel: { items: ['terminal'] }, + }); + await flush(); + act(() => { + container + .querySelector( + 'button[aria-label="Toggle right panel"]', + ) + ?.click(); + }); + await flush(); + act(() => { + Array.from(document.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Terminal')) + ?.click(); + }); + await flush(); + const terminal = document.querySelector('[data-web-terminal]'); + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + + await act(async () => { + terminal?.dispatchEvent(escape); + await Promise.resolve(); + }); + await flush(); + + expect(escape.defaultPrevented).toBe(true); + expect( + document.querySelector('aside[aria-label="Right panel"]'), + ).not.toBeNull(); + }); + it('leaves an IME-composition Escape to the composer in the fullscreen drawer', async () => { // No min-width query matches: the panel floats in a drawer instead of // docking. @@ -4074,6 +4123,41 @@ describe('artifact panel fullscreen', () => { ).not.toBeNull(); }); + it('leaves terminal Escape to xterm while the session drawer is open', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + const host = document.createElement('div'); + const shadow = host.attachShadow({ mode: 'open' }); + document.body.append(host); + const terminal = document.createElement('div'); + terminal.dataset['webTerminal'] = ''; + const input = document.createElement('textarea'); + terminal.append(input); + shadow.append(terminal); + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + composed: true, + }); + + await act(async () => { + input.dispatchEvent(escape); + await Promise.resolve(); + }); + + expect(escape.defaultPrevented).toBe(false); + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).not.toBeNull(); + host.remove(); + }); + it('reveals the covered shells in the commit that closes the fullscreen panel', async () => { const { container } = renderApp(); await flush(); @@ -5358,8 +5442,8 @@ describe('App global shortcuts', () => { ); }); - it('leaves terminal key chords to xterm', async () => { - renderApp(); + it.each(['o', 'b'])('leaves Ctrl+%s to xterm', async (key) => { + const { container } = renderApp(); const host = document.createElement('div'); const shadow = host.attachShadow({ mode: 'open' }); document.body.append(host); @@ -5373,8 +5457,10 @@ describe('App global shortcuts', () => { cancelable: true, composed: true, ctrlKey: true, - key: 'o', + key, }); + const sidebar = container.querySelector('[data-testid="sidebar"]'); + const collapsed = sidebar?.getAttribute('data-collapsed'); await act(async () => { input.dispatchEvent(event); @@ -5382,6 +5468,7 @@ describe('App global shortcuts', () => { }); expect(event.defaultPrevented).toBe(false); + expect(sidebar?.getAttribute('data-collapsed')).toBe(collapsed); host.remove(); }); }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 7b944783628..4febb48cfda 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -2096,6 +2096,7 @@ export function App({ if (!mobileDrawerOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; + if (isWebTerminalTarget(e)) return; // A pending tool/permission approval owns Escape (it rejects the call), // so don't let the drawer swallow it while a prompt is visible. if (pendingApprovalRef.current) return; @@ -2154,6 +2155,7 @@ export function App({ if (!sidebarOptions.enabled) return undefined; const onKey = (e: KeyboardEvent) => { if (!isSidebarToggleShortcut(e)) return; + if (isWebTerminalTarget(e)) return; // The composer keeps the editor-convention behavior (VS Code toggles // the sidebar while the editor is focused), but other editable targets // — sidebar search, session rename, settings inputs — must not have @@ -13812,6 +13814,10 @@ export function App({ : 'data-[vaul-drawer-direction=right]:w-[min(520px,calc(100vw-16px))] data-[vaul-drawer-direction=right]:sm:max-w-[520px]' } onEscapeKeyDown={(event) => { + if (isWebTerminalTarget(event)) { + event.preventDefault(); + return; + } if (event.isComposing || event.keyCode === 229) { // IME owns Escape; keep the dismiss layer from acting // on it. The preserveImeEscape mask above normally From 7dca447c5bf9f5a32878ccb8e2d1dc1d8f14613f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Fri, 28 Aug 2026 09:51:57 +0800 Subject: [PATCH 09/11] fix(web-shell): harden terminal session cleanup --- .../cli/src/serve/routes/terminal.test.ts | 26 +++++--- packages/cli/src/serve/routes/terminal.ts | 10 +++- .../services/web-terminal-registry.test.ts | 60 ++++++++++++++++--- .../src/services/web-terminal-registry.ts | 47 +++++++++++++-- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts index f8e253fb8da..3457d9dbee3 100644 --- a/packages/cli/src/serve/routes/terminal.test.ts +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -247,13 +247,15 @@ describe('terminal WebSocket route', () => { expect(ws.close).toHaveBeenCalledWith(4002, 'Session unavailable'); }); - it('forwards a live PTY exit and closes the WebSocket', async () => { + it('preserves a live PTY exit until a reconnect replays it', async () => { let exit: ((event: { exitCode: number }) => void) | undefined; - const registry = registryWithSnapshot({ + const snapshot = { output: '', exited: false, workspaceCwd: '/workspace', - }); + exitCode: undefined as number | undefined, + }; + const registry = registryWithSnapshot(snapshot); vi.mocked(registry.addExitListener).mockImplementationOnce( (_terminalId, listener) => { exit = listener; @@ -261,15 +263,23 @@ describe('terminal WebSocket route', () => { }, ); const ws = new FakeWebSocket(); - await createTerminalWsHandler(registry, resolveWorkspace).onConnection( - ws as unknown as WebSocket, - request, - ); + const handler = createTerminalWsHandler(registry, resolveWorkspace); + await handler.onConnection(ws as unknown as WebSocket, request); + snapshot.output = 'done\r\n'; + snapshot.exited = true; + snapshot.exitCode = 9; + Object.defineProperty(ws, 'readyState', { value: 3 }); exit?.({ exitCode: 9 }); - expect(ws.sent).toContain('\x00{"type":"exit","exitCode":9}'); expect(ws.close).toHaveBeenCalledWith(4000, 'Terminal exited'); + expect(registry.release).not.toHaveBeenCalled(); + + const reconnect = new FakeWebSocket(); + await handler.onConnection(reconnect as unknown as WebSocket, request); + + expect(sentOutput(reconnect)).toEqual(['done\r\n']); + expect(reconnect.sent).toContain('\x00{"type":"exit","exitCode":9}'); expect(registry.release).toHaveBeenCalledWith( 'terminal:manual-1', '/workspace', diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts index 2dfd6ccb9eb..3f4f03809a3 100644 --- a/packages/cli/src/serve/routes/terminal.ts +++ b/packages/cli/src/serve/routes/terminal.ts @@ -237,10 +237,14 @@ export function createTerminalWsHandler( detach.exit?.(); ws.off('message', onMessage); }; - const finishExited = (exitCode: number | undefined) => { + const finishExited = ( + exitCode: number | undefined, + releaseAfterReplay = false, + ) => { sendControl(ws, { type: 'exit', exitCode }); cleanup(); - registry.release(terminalId, workspace.workspaceCwd); + if (releaseAfterReplay) + registry.release(terminalId, workspace.workspaceCwd); ws.close(4000, 'Terminal exited'); }; const onMessage = (data: unknown, isBinary = false) => { @@ -331,7 +335,7 @@ export function createTerminalWsHandler( return; } if (snapshot.exited) { - finishExited(snapshot.exitCode); + finishExited(snapshot.exitCode, true); return; } for (const message of pending) { diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts index 07fcf78af90..4193eec695a 100644 --- a/packages/core/src/services/web-terminal-registry.test.ts +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -6,11 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { spawn, getPty } = vi.hoisted(() => ({ +const { spawn, getPty, spawnSync } = vi.hoisted(() => ({ spawn: vi.fn(), getPty: vi.fn(), + spawnSync: vi.fn(), })); +vi.mock('node:child_process', () => ({ spawnSync })); vi.mock('../utils/getPty.js', () => ({ getPty })); import { @@ -35,6 +37,7 @@ describe('WebTerminalRegistry', () => { kill = vi.fn(); disposeData = vi.fn(); disposeExit = vi.fn(); + spawnSync.mockReturnValue({ stdout: '' }); spawn.mockReturnValue({ pid: 1, write, @@ -249,26 +252,69 @@ describe('WebTerminalRegistry', () => { const detachListener = registry.addOutputListener('terminal:io', listener); expect(registry.write('terminal:io', 'x'.repeat(256 * 1024 + 1))).toBe( + 'backpressure', + ); + expect(write).not.toHaveBeenCalled(); + expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe( 'written', ); - expect(registry.write('terminal:io', '\x03')).toBe('backpressure'); + expect(registry.write('terminal:io', 'x')).toBe('backpressure'); expect(write).toHaveBeenCalledOnce(); expect(registry.resize('terminal:io', 120, 40)).toBe(true); expect(resize).toHaveBeenCalledWith(120, 40); detachListener?.(); registry.addOutputListener('terminal:io', listener); - expect(registry.write('terminal:io', '\x03')).toBe('written'); - expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe( - 'written', - ); expect(registry.write('terminal:io', 'x')).toBe('backpressure'); onData('ready'); expect(listener).toHaveBeenCalledWith('ready'); - expect(registry.write('terminal:io', 'x')).toBe('written'); + expect(registry.write('terminal:io', '12345')).toBe('written'); + expect(registry.write('terminal:io', 'x')).toBe('backpressure'); }); + it.skipIf(process.platform === 'win32')( + 'kills every process in the PTY session', + async () => { + spawn.mockReturnValueOnce({ + pid: 42, + write, + resize, + kill, + onData: vi.fn((listener) => { + onData = listener; + return { dispose: disposeData }; + }), + onExit: vi.fn((listener) => { + onExit = listener; + return { dispose: disposeExit }; + }), + }); + spawnSync.mockReturnValueOnce({ + stdout: '42 1 42\n43 42 42\n44 43 42\n99 1 99\n', + }); + const processKill = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + try { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:process-tree', + workspaceCwd: '/workspace', + }); + + registry.release('terminal:process-tree'); + + expect(processKill).toHaveBeenCalledWith(42, 'SIGKILL'); + expect(processKill).toHaveBeenCalledWith(43, 'SIGKILL'); + expect(processKill).toHaveBeenCalledWith(44, 'SIGKILL'); + expect(processKill).not.toHaveBeenCalledWith(99, 'SIGKILL'); + } finally { + processKill.mockRestore(); + } + }, + ); + it('does not count exited sessions against the live terminal limit', async () => { const registry = new WebTerminalRegistry(); for (let i = 0; i < MAX_CONCURRENT_WEB_TERMINALS; i++) { diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts index 922d8e82cf5..d271e0b1e12 100644 --- a/packages/core/src/services/web-terminal-registry.ts +++ b/packages/core/src/services/web-terminal-registry.ts @@ -78,6 +78,37 @@ function killPtyTree(pty: WebTerminalPty): void { } } else { if (pty.pid > 1) { + const processes = spawnSync('ps', ['-axo', 'pid=,ppid=,sess='], { + encoding: 'utf8', + }); + const rows = (processes.stdout?.split('\n') ?? []).map((line) => + line.trim().split(/\s+/).map(Number), + ); + const targets = new Set([pty.pid]); + let found = true; + while (found) { + found = false; + for (const [pid, parentPid, sessionId] of rows) { + if ( + pid > 1 && + !targets.has(pid) && + (targets.has(parentPid) || sessionId === pty.pid) + ) { + targets.add(pid); + found = true; + } + } + } + let killed = false; + for (const pid of [...targets].reverse()) { + try { + process.kill(pid, 'SIGKILL'); + killed = true; + } catch { + // Process already exited. + } + } + if (killed) return; try { process.kill(-pty.pid, 'SIGKILL'); return; @@ -174,7 +205,10 @@ export class WebTerminalRegistry { earlyOutput.push(data); return; } - session.unacknowledgedInputBytes = 0; + session.unacknowledgedInputBytes = Math.max( + 0, + session.unacknowledgedInputBytes - Buffer.byteLength(data), + ); if (Buffer.byteLength(data) > MAX_BUFFER_BYTES) { data = Buffer.from(data).subarray(-MAX_BUFFER_BYTES).toString('utf8'); while (Buffer.byteLength(data) > MAX_BUFFER_BYTES) data = data.slice(1); @@ -261,9 +295,6 @@ export class WebTerminalRegistry { ): (() => void) | undefined { const session = this.sessions.get(terminalId); if (!session) return undefined; - if (session.outputListeners.size === 0) { - session.unacknowledgedInputBytes = 0; - } session.outputListeners.add(listener); this.clearReclaim(session); return () => { @@ -299,12 +330,16 @@ export class WebTerminalRegistry { write(terminalId: string, data: string): WebTerminalWriteResult { const session = this.sessions.get(terminalId); if (!session || session.exited) return 'unavailable'; - if (session.unacknowledgedInputBytes >= MAX_UNACKNOWLEDGED_INPUT_BYTES) { + const bytes = Buffer.byteLength(data); + if ( + session.unacknowledgedInputBytes + bytes > + MAX_UNACKNOWLEDGED_INPUT_BYTES + ) { return 'backpressure'; } try { session.pty.write(data); - session.unacknowledgedInputBytes += Buffer.byteLength(data); + session.unacknowledgedInputBytes += bytes; return 'written'; } catch { return 'unavailable'; From c36212ef29ccfa5017589e26b432863e0ae355d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Fri, 28 Aug 2026 13:00:53 +0800 Subject: [PATCH 10/11] fix(web-shell): make terminal limits recoverable --- .../services/web-terminal-registry.test.ts | 85 +++++++++++++++++-- .../src/services/web-terminal-registry.ts | 49 ++++++++--- 2 files changed, 114 insertions(+), 20 deletions(-) diff --git a/packages/core/src/services/web-terminal-registry.test.ts b/packages/core/src/services/web-terminal-registry.test.ts index 4193eec695a..11c157996bf 100644 --- a/packages/core/src/services/web-terminal-registry.test.ts +++ b/packages/core/src/services/web-terminal-registry.test.ts @@ -251,15 +251,13 @@ describe('WebTerminalRegistry', () => { const listener = vi.fn(); const detachListener = registry.addOutputListener('terminal:io', listener); - expect(registry.write('terminal:io', 'x'.repeat(256 * 1024 + 1))).toBe( - 'backpressure', - ); - expect(write).not.toHaveBeenCalled(); expect(registry.write('terminal:io', 'x'.repeat(256 * 1024))).toBe( 'written', ); + expect(registry.write('terminal:io', '\x03')).toBe('written'); + expect(registry.write('terminal:io', '\x04')).toBe('written'); expect(registry.write('terminal:io', 'x')).toBe('backpressure'); - expect(write).toHaveBeenCalledOnce(); + expect(write).toHaveBeenCalledTimes(3); expect(registry.resize('terminal:io', 120, 40)).toBe(true); expect(resize).toHaveBeenCalledWith(120, 40); @@ -273,6 +271,19 @@ describe('WebTerminalRegistry', () => { expect(registry.write('terminal:io', 'x')).toBe('backpressure'); }); + it('accepts one complete oversized input frame on an idle session', async () => { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:paste', + workspaceCwd: '/workspace', + }); + const paste = 'x'.repeat(256 * 1024 + 1); + + expect(registry.write('terminal:paste', paste)).toBe('written'); + expect(registry.write('terminal:paste', paste)).toBe('backpressure'); + expect(write).toHaveBeenCalledOnce(); + }); + it.skipIf(process.platform === 'win32')( 'kills every process in the PTY session', async () => { @@ -291,7 +302,7 @@ describe('WebTerminalRegistry', () => { }), }); spawnSync.mockReturnValueOnce({ - stdout: '42 1 42\n43 42 42\n44 43 42\n99 1 99\n', + stdout: '42 1 42\n43 42 42\n44 43 42\n45 1 42\n99 1 99\n', }); const processKill = vi .spyOn(process, 'kill') @@ -305,9 +316,30 @@ describe('WebTerminalRegistry', () => { registry.release('terminal:process-tree'); + expect(spawnSync).toHaveBeenCalledWith( + 'ps', + [ + '-A', + '-o', + process.platform === 'linux' + ? 'pid=,ppid=,sid=' + : process.platform === 'darwin' + ? 'pid=,ppid=,tdev=' + : 'pid=,ppid=', + ], + expect.objectContaining({ + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + timeout: 2_000, + }), + ); expect(processKill).toHaveBeenCalledWith(42, 'SIGKILL'); expect(processKill).toHaveBeenCalledWith(43, 'SIGKILL'); expect(processKill).toHaveBeenCalledWith(44, 'SIGKILL'); + if (process.platform === 'linux' || process.platform === 'darwin') { + expect(processKill).toHaveBeenCalledWith(45, 'SIGKILL'); + } + expect(processKill).toHaveBeenCalledWith(-42, 'SIGKILL'); expect(processKill).not.toHaveBeenCalledWith(99, 'SIGKILL'); } finally { processKill.mockRestore(); @@ -315,6 +347,47 @@ describe('WebTerminalRegistry', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'falls back to the PTY process group when ps fails', + async () => { + spawn.mockReturnValueOnce({ + pid: 42, + write, + resize, + kill, + onData: vi.fn((listener) => { + onData = listener; + return { dispose: disposeData }; + }), + onExit: vi.fn((listener) => { + onExit = listener; + return { dispose: disposeExit }; + }), + }); + spawnSync.mockReturnValueOnce({ + stdout: '', + error: Object.assign(new Error('ps failed'), { code: 'ENOBUFS' }), + }); + const processKill = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + try { + const registry = new WebTerminalRegistry(); + await registry.create({ + terminalId: 'terminal:process-group', + workspaceCwd: '/workspace', + }); + + registry.release('terminal:process-group'); + + expect(processKill).toHaveBeenCalledWith(-42, 'SIGKILL'); + expect(kill).toHaveBeenCalledOnce(); + } finally { + processKill.mockRestore(); + } + }, + ); + it('does not count exited sessions against the live terminal limit', async () => { const registry = new WebTerminalRegistry(); for (let i = 0; i < MAX_CONCURRENT_WEB_TERMINALS; i++) { diff --git a/packages/core/src/services/web-terminal-registry.ts b/packages/core/src/services/web-terminal-registry.ts index d271e0b1e12..f819ba3772b 100644 --- a/packages/core/src/services/web-terminal-registry.ts +++ b/packages/core/src/services/web-terminal-registry.ts @@ -78,40 +78,58 @@ function killPtyTree(pty: WebTerminalPty): void { } } else { if (pty.pid > 1) { - const processes = spawnSync('ps', ['-axo', 'pid=,ppid=,sess='], { - encoding: 'utf8', - }); - const rows = (processes.stdout?.split('\n') ?? []).map((line) => - line.trim().split(/\s+/).map(Number), + const scopeColumn = + process.platform === 'linux' + ? 'sid=' + : process.platform === 'darwin' + ? 'tdev=' + : undefined; + const processes = spawnSync( + 'ps', + ['-A', '-o', scopeColumn ? `pid=,ppid=,${scopeColumn}` : 'pid=,ppid='], + { + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + timeout: 2_000, + }, ); + const rows = (processes.stdout?.split('\n') ?? []).flatMap((line) => { + const [pidText, parentPidText, scope] = line.trim().split(/\s+/); + const pid = Number(pidText); + const parentPid = Number(parentPidText); + return Number.isInteger(pid) && Number.isInteger(parentPid) + ? [{ pid, parentPid, scope }] + : []; + }); + const rootScope = rows.find(({ pid }) => pid === pty.pid)?.scope; const targets = new Set([pty.pid]); let found = true; while (found) { found = false; - for (const [pid, parentPid, sessionId] of rows) { + for (const { pid, parentPid, scope } of rows) { if ( pid > 1 && !targets.has(pid) && - (targets.has(parentPid) || sessionId === pty.pid) + (targets.has(parentPid) || + (rootScope !== undefined && + rootScope !== '0' && + rootScope !== '??' && + scope === rootScope)) ) { targets.add(pid); found = true; } } } - let killed = false; for (const pid of [...targets].reverse()) { try { process.kill(pid, 'SIGKILL'); - killed = true; } catch { // Process already exited. } } - if (killed) return; try { process.kill(-pty.pid, 'SIGKILL'); - return; } catch { // Fall through when the process group has already exited. } @@ -331,15 +349,18 @@ export class WebTerminalRegistry { const session = this.sessions.get(terminalId); if (!session || session.exited) return 'unavailable'; const bytes = Buffer.byteLength(data); + const isRecoveryControl = + data.length === 1 && '\x03\x04\x1a\x1c'.includes(data); if ( - session.unacknowledgedInputBytes + bytes > - MAX_UNACKNOWLEDGED_INPUT_BYTES + !isRecoveryControl && + session.unacknowledgedInputBytes !== 0 && + session.unacknowledgedInputBytes + bytes > MAX_UNACKNOWLEDGED_INPUT_BYTES ) { return 'backpressure'; } try { session.pty.write(data); - session.unacknowledgedInputBytes += bytes; + if (!isRecoveryControl) session.unacknowledgedInputBytes += bytes; return 'written'; } catch { return 'unavailable'; From 34a5b0a12bbb71768f95402467809bd4ae2c7bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Fri, 28 Aug 2026 13:19:29 +0800 Subject: [PATCH 11/11] fix(web-shell): isolate terminal sessions --- .../cli/src/serve/routes/terminal.test.ts | 32 +++++++++++++++ packages/cli/src/serve/routes/terminal.ts | 39 +++++++++---------- packages/web-shell/client/App.tsx | 3 +- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/serve/routes/terminal.test.ts b/packages/cli/src/serve/routes/terminal.test.ts index 3457d9dbee3..828b0828c94 100644 --- a/packages/cli/src/serve/routes/terminal.test.ts +++ b/packages/cli/src/serve/routes/terminal.test.ts @@ -705,6 +705,38 @@ describe('terminal WebSocket route', () => { }, ); + it('stops output when the workspace becomes unavailable', async () => { + let available = true; + let output: ((data: string) => void) | undefined; + const registry = registryWithSnapshot({ + output: '', + exited: false, + workspaceCwd: '/workspace', + }); + vi.mocked(registry.addOutputListener).mockImplementationOnce( + (_terminalId, listener) => { + output = listener; + return vi.fn(); + }, + ); + const ws = new FakeWebSocket(); + await createTerminalWsHandler(registry, () => + available ? context : undefined, + ).onConnection(ws as unknown as WebSocket, request); + + available = false; + output?.('secret output'); + + expect(sentOutput(ws)).not.toContain('secret output'); + expect(ws.sent).toContain( + '\x00{"type":"error","message":"Terminal workspace unavailable"}', + ); + expect(ws.close).toHaveBeenCalledWith( + 4002, + 'Terminal workspace unavailable', + ); + }); + it('rejects create failures with a non-retryable close code', async () => { const registry = registryWithSnapshot(undefined); vi.mocked(registry.create).mockResolvedValueOnce({ diff --git a/packages/cli/src/serve/routes/terminal.ts b/packages/cli/src/serve/routes/terminal.ts index 3f4f03809a3..a22edeb35d4 100644 --- a/packages/cli/src/serve/routes/terminal.ts +++ b/packages/cli/src/serve/routes/terminal.ts @@ -247,6 +247,21 @@ export function createTerminalWsHandler( registry.release(terminalId, workspace.workspaceCwd); ws.close(4000, 'Terminal exited'); }; + const ensureWorkspaceAvailable = () => { + if ( + resolveWorkspace(workspaceSelector)?.workspaceCwd === + workspace.workspaceCwd + ) { + return true; + } + sendControl(ws, { + type: 'error', + message: 'Terminal workspace unavailable', + }); + cleanup(); + ws.close(4002, 'Terminal workspace unavailable'); + return false; + }; const onMessage = (data: unknown, isBinary = false) => { const text = toText(data); const control = isBinary ? null : parseControl(text); @@ -260,29 +275,11 @@ export function createTerminalWsHandler( return; } if (control?.type === 'resize') { - const currentWorkspace = resolveWorkspace(workspaceSelector); - if (currentWorkspace?.workspaceCwd !== workspace.workspaceCwd) { - sendControl(ws, { - type: 'error', - message: 'Terminal workspace unavailable', - }); - cleanup(); - ws.close(4002, 'Terminal workspace unavailable'); - return; - } + if (!ensureWorkspaceAvailable()) return; registry.resize(terminalId, control.cols, control.rows); return; } - const currentWorkspace = resolveWorkspace(workspaceSelector); - if (currentWorkspace?.workspaceCwd !== workspace.workspaceCwd) { - sendControl(ws, { - type: 'error', - message: 'Terminal workspace unavailable', - }); - cleanup(); - ws.close(4002, 'Terminal workspace unavailable'); - return; - } + if (!ensureWorkspaceAvailable()) return; const writeResult = registry.write(terminalId, text); if (writeResult !== 'written') { sendControl(ws, { @@ -303,6 +300,7 @@ export function createTerminalWsHandler( }; detach.output = registry.addOutputListener(terminalId, (data) => { + if (!ensureWorkspaceAvailable()) return; if (!sendOutput(ws, data)) { cleanup(); ws.close(1013, 'Terminal output backpressure'); @@ -329,6 +327,7 @@ export function createTerminalWsHandler( ws.on('close', cleanup); ws.off('error', markClosed); ws.on('error', cleanup); + if (!ensureWorkspaceAvailable()) return; if (!sendOutput(ws, snapshot.output)) { cleanup(); ws.close(1013, 'Terminal output backpressure'); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 4febb48cfda..5792227be1a 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -3157,9 +3157,8 @@ export function App({ if (createSideTask()) return; pushToast('error', t('sideTask.createFailed')); }, [createSideTask, pushToast, t]); - const nextTerminalTabIdRef = useRef(0); const openTerminalTab = useCallback(() => { - const id = `terminal:${Date.now()}:${++nextTerminalTabIdRef.current}`; + const id = `terminal:${crypto.randomUUID()}`; const count = artifactPanelTabsRef.current.filter( (tab) => tab.kind === 'terminal', ).length;