Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/design/web-terminal-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Colors, modes and cursor/geometry queries belong to the browser that renders the
live output. Headless does not supply those answers. PTY output and bounded
scrollback retain their original bytes, including split escape sequences.

Queries emitted while no browser is attached remain unanswered on reconnect: all
history is replayed without replies, even if a query was never answered live.
Applications waiting for browser-owned replies must tolerate a timeout or retry.

## Transport and compatibility

The workspace-resolved `/terminal` connection requests `replay=1`. Before the
Expand All @@ -40,7 +44,8 @@ interpreted as control frames. A newly created PTY uses `replay:false`: its
buffered startup queries have not been answered by a browser yet.

Legacy clients are rejected before PTY creation with a reload message. The new
client rejects an unmarked binary snapshot from an older daemon. Both must be
client releases the PTY before rejecting an unmarked binary snapshot from an
older daemon, with a localized restart notice. Both must be
updated together; release-only connections still work. Workspace validation,
ownership checks, heartbeat and output/input backpressure remain unchanged.

Expand Down
4 changes: 3 additions & 1 deletion docs/design/web-terminal-replay.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Windows headless 终端解析原始流,但只转发 primary DA 应答,包括

颜色、模式、光标和几何查询由渲染实时输出的浏览器应答。headless 不提供这些答案。PTY 输出及有界 scrollback 保留原始字节,包括跨块的转义序列。

浏览器未连接期间发出的查询不会在重连时补答:所有历史回放都抑制应答,即使该查询此前从未被实时应答。等待浏览器应答的程序需要容忍超时或重试。

## 传输与兼容性

已解析工作区的 `/terminal` 连接携带 `replay=1`。服务端在首个二进制输出前发送以 NUL 开头的 JSON 控制帧:
Expand All @@ -24,7 +26,7 @@ Windows headless 终端解析原始流,但只转发 primary DA 应答,包括

下一个二进制帧是快照,后续二进制帧是实时输出。继续以二进制传输输出,既保留大小边界,也避免将 PTY 字节解释成控制帧。新建 PTY 使用 `replay:false`,因为浏览器还没有应答其缓存的启动查询。

旧客户端会在创建 PTY 前被拒绝,并收到刷新提示。新客户端拒绝旧 daemon 未标记的二进制快照。两端需要同步升级;仅释放会话的连接仍可使用。工作区校验、归属检查、心跳以及输入输出背压保持不变。
旧客户端会在创建 PTY 前被拒绝,并收到刷新提示。新客户端在拒绝旧 daemon 未标记的二进制快照前释放 PTY,并显示本地化的重启提示。两端需要同步升级;仅释放会话的连接仍可使用。工作区校验、归属检查、心跳以及输入输出背压保持不变。

## 浏览器恢复

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/services/web-terminal-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,15 @@ describe('WebTerminalRegistry', () => {

it('reports a spawn failure and frees the id when both backends fail', async () => {
osPlatform.mockReturnValue('win32');
const responder = new Terminal();
const dispose = vi.spyOn(responder, 'dispose');
loadXtermHeadless.mockResolvedValueOnce({
Terminal: class {
constructor() {
return responder;
}
},
});
spawn.mockImplementation(() => {
throw new Error('spawn failed');
});
Expand All @@ -473,6 +482,7 @@ describe('WebTerminalRegistry', () => {
}),
).resolves.toEqual({ error: 'Failed to spawn shell' });
expect(spawn).toHaveBeenCalledTimes(2);
expect(dispose).toHaveBeenCalledOnce();

// finishCreating ran on the failure path: the same id is creatable again
// instead of being stuck on "is being created".
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,14 @@ describe('TerminalPanel', () => {
ws.open();
ws.message(new TextEncoder().encode('unmarked history').buffer);
});
expect(ws.send).toHaveBeenCalledWith('\x00{"type":"release"}');
expect(ws.send.mock.invocationCallOrder.at(-1)).toBeLessThan(
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
ws.close.mock.invocationCallOrder[0]!,
);
expect(ws.close).toHaveBeenCalledWith(4002, 'Terminal protocol mismatch');
const sent = ws.send.mock.calls.length;
act(() => releaseWebTerminal('terminal:one'));
expect(ws.send).toHaveBeenCalledTimes(sent);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
expect(terminal.write).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,9 @@ export function TerminalPanel({
restoreSnapshot(text, ws);
} else if (awaitingSnapshot) {
ended = true;
term.writeln(
'\r\nTerminal protocol changed; restart the daemon and reload this page.',
);
releaseRequested = true;
ws.send(CONTROL_FRAME_PREFIX + JSON.stringify({ type: 'release' }));
term.writeln(`\r\n${t('terminal.notice.protocolMismatch')}`);
ws.close(4002, 'Terminal protocol mismatch');
} else {
(restoring?.term ?? term).write(text);
Expand Down
4 changes: 4 additions & 0 deletions packages/web-shell/client/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,8 @@ const EN: Messages = {
'terminal.notice.error': (v) => `Error: ${v?.message ?? ''}`,
'terminal.notice.unknownError': 'Unknown error',
'terminal.notice.reconnecting': 'Connection lost — reconnecting…',
'terminal.notice.protocolMismatch':
'Terminal protocol changed; restart the daemon and reload this page.',
'localFiles.title': 'Local files',
'localFiles.trigger': 'Local files',
'localFiles.hint':
Expand Down Expand Up @@ -5164,6 +5166,8 @@ const ZH: Messages = {
'terminal.notice.error': (v) => `错误:${v?.message ?? ''}`,
'terminal.notice.unknownError': '未知错误',
'terminal.notice.reconnecting': '连接已断开,正在重连…',
'terminal.notice.protocolMismatch':
'终端协议已更新,请重启 daemon 并刷新页面。',
'localFiles.title': '本地文件',
'localFiles.trigger': '本地文件',
'localFiles.hint':
Expand Down
2 changes: 2 additions & 0 deletions packages/web-shell/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export default defineConfig(({ command }) => ({
dedupe: ['react', 'react-dom', '@qwen-code/sdk'],
},
build: {
// Avoid esbuild lowering xterm's logical assignments into invalid code.
target: 'es2021',
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
outDir: '../dist',
emptyOutDir: true,
},
Expand Down
36 changes: 36 additions & 0 deletions scripts/tests/package-assets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,42 @@ describe('package asset scripts', () => {
expect(distPackageJson.optionalDependencies.sharp).toBe('0.35.3');
});

it('derives every published node-pty pin from the core manifest', () => {
const rootDir = createFixtureRoot();
const corePath = path.join(rootDir, 'packages/core/package.json');
const core = JSON.parse(readFileSync(corePath, 'utf8'));
const pins = Object.fromEntries(
Object.entries(
JSON.parse(
readFileSync(
new URL('../../packages/core/package.json', import.meta.url),
'utf8',
),
).optionalDependencies,
)
.filter(([name]) => name.startsWith('@lydell/node-pty'))
.map(([name]) => [name, '1.2.0-test-pin']),
);
expect(Object.keys(pins)).toHaveLength(6);
core.optionalDependencies = pins;
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
writeFileSync(corePath, JSON.stringify(core));
createBundleArtifacts(rootDir);
stubConsole();

preparePackage({ rootDir, requireNativeAudioCapture: false });

const published = JSON.parse(
readFileSync(path.join(rootDir, 'dist/package.json'), 'utf8'),
);
expect(
Object.fromEntries(
Object.entries(published.optionalDependencies).filter(([name]) =>
name.startsWith('@lydell/node-pty'),
),
),
).toEqual(pins);
});

it('rejects a locked sharp version outside the core declaration', () => {
const rootDir = createFixtureRoot();
writeFile(
Expand Down
97 changes: 97 additions & 0 deletions scripts/tests/web-terminal-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import { resolveConfig } from 'vite';
import { JSDOM } from 'jsdom';
// Use the repository's compiler; a locally overridden Vite dependency may
// already fix the logical-assignment lowering bug and mask this regression.
import { transform } from 'esbuild';

const require = createRequire(import.meta.url);
const webShellRoot = resolve(import.meta.dirname, '../../packages/web-shell');

interface TestTerminal {
onData(listener: (data: string) => void): void;
_core: { _inputHandler: { parse(data: string): void } };
buffer: {
active: { getLine(index: number): { translateToString(): string } };
};
dispose(): void;
}

async function builtTerminal(target: string | string[]) {
const source = await readFile(
resolve(dirname(require.resolve('@xterm/xterm')), 'xterm.mjs'),
'utf8',
);
const { code } = await transform(source, {
target,
minify: true,
format: 'cjs',
});
const module = {
exports: {} as {
Terminal: new (options: { allowProposedApi: boolean }) => TestTerminal;
},
};
const { window } = new JSDOM();
Object.defineProperty(window.HTMLCanvasElement.prototype, 'getContext', {
value: () => null,
});
runInNewContext(code, {
window,
document: window.document,
navigator: window.navigator,
module,
exports: module.exports,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
queueMicrotask,
console,
performance,
});
return new module.exports.Terminal({ allowProposedApi: true });
}

function queryAndPrint(terminal: TestTerminal) {
const replies: string[] = [];
terminal.onData((data) => replies.push(data));
// Parsing synchronously exposes failures that xterm's asynchronous write queue
// otherwise reports outside the test, without requiring a browser renderer.
terminal._core._inputHandler.parse('\x1b[?2004$pafter-query');
return {
replies,
output: terminal.buffer.active.getLine(0).translateToString().trimEnd(),
};
}

describe('Web Shell production terminal', () => {
it('answers DECRQM and keeps processing output after minification', async () => {
const config = await resolveConfig(
{
root: webShellRoot,
configFile: resolve(webShellRoot, 'vite.config.ts'),
},
'build',
);
const terminal = await builtTerminal(config.build.target || 'esnext');
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
try {
expect(queryAndPrint(terminal)).toEqual({
replies: ['\x1b[?2004;2$y'],
output: 'after-query',
});
} finally {
terminal.dispose();
}
});
});
Loading