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
69 changes: 69 additions & 0 deletions docs/design/web-terminal-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Web terminal replay and query ownership

[English](web-terminal-replay.md) | [简体中文](web-terminal-replay.zh-CN.md)

## Goal and scope

PR #11643 switches Windows web-terminal PTYs to bundled ConPTY to avoid the
inbox backend's natural-exit host leak. Its review follow-up #11734 requires
preserving live terminal answers without answering old queries on reconnect.
The cross-package correction was approved on 2026-09-13.

Remove the server's escape-sequence filters. This does not change POSIX PTY
backend selection, add dependencies, or broaden agent-view behavior. The replay
boundary applies to Web Shell terminals on all hosts, not only VS Code.

## Ownership

The Windows headless terminal parses the original stream but forwards only its
primary DA answer, including before a browser attaches. Snapshot metadata tells
the browser to consume primary DA through xterm's native CSI handler in that
case. When headless cannot load, the browser owns live DA; a startup probe before
attachment may time out. The existing inbox spawn-failure retry remains.

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.

## Transport and compatibility

The workspace-resolved `/terminal` connection requests `replay=1`. Before the
initial binary output, the server sends a NUL-prefixed JSON control frame:

```json
{ "type": "snapshot", "replay": true, "handlesPrimaryDa": true }
```

The next binary frame is the snapshot; later binary frames are live output.
Keeping output binary preserves its size bound and prevents PTY bytes from being
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
updated together; release-only connections still work. Workspace validation,
ownership checks, heartbeat and output/input backpressure remain unchanged.

## Browser restoration

Restore history into a terminal opened on a detached DOM host without an input
forwarder. Keep the old visible terminal forwarding keyboard/paste/IME input.
The snapshot write callback installs the new forwarder and swaps the visible
host before queued live writes parse. Dispose the old terminal after the swap.
Fresh startup output gets its forwarder before parsing. A retryable disconnection
or unmount disposes an unfinished restore; a stale callback cannot replace a
newer connection's view. This uses public xterm APIs, not a global flag that drops
both replies and user input.

## Verification and acceptance

- Registry: original live/snapshot bytes survive; only primary DA is answered.
- Route: snapshot metadata precedes binary history; fresh and reconnect paths
differ; old clients fail clearly without spawning.
- Browser: replay produces no outgoing answers, queued live color/mode queries
do, and typing during replay still reaches stdin. Initial queries work;
primary DA has one owner; interrupted replay cannot replace the current view.
- Run focused tests and a mounted real-browser check after implementation.
Native Windows process counts, fallback DLL loading and interactive rendering
require separate Windows acceptance. Browser evidence is not proof of the
original native leak fix.
38 changes: 38 additions & 0 deletions docs/design/web-terminal-replay.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Web terminal 回放与查询应答归属

[English](web-terminal-replay.md) | [简体中文](web-terminal-replay.zh-CN.md)

## 目标与范围

PR #11643 将 Windows web-terminal PTY 切到 bundled ConPTY,避免 inbox 后端在自然退出时泄漏 host。其评审后续 #11734 要求保留实时终端应答,并且重连时不再应答历史查询。2026-09-13 已批准这次跨包修正。

移除服务端转义序列过滤器。不改变 POSIX PTY 后端选择,不增加依赖,也不扩展 agent-view 行为。回放边界适用于所有平台的 Web Shell 终端,并非仅限 VS Code。

## 应答归属

Windows headless 终端解析原始流,但只转发 primary DA 应答,包括浏览器尚未连接时的探测。快照元数据通知浏览器在这种情况下通过 xterm 原生 CSI handler 消费 primary DA。headless 无法加载时,浏览器负责实时 DA;连接前的启动探测可能超时。保留现有 inbox spawn 失败回退。

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

## 传输与兼容性

已解析工作区的 `/terminal` 连接携带 `replay=1`。服务端在首个二进制输出前发送以 NUL 开头的 JSON 控制帧:

```json
{ "type": "snapshot", "replay": true, "handlesPrimaryDa": true }
```

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

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

## 浏览器恢复

在脱离页面的 DOM 容器中打开新终端,不安装输入转发器,用它恢复历史。旧的可见终端继续转发键盘、粘贴和 IME 输入。快照 write 回调安装新转发器并切换可见容器,然后排队的实时 write 才会解析。切换后释放旧终端。首次启动输出在解析前就安装转发器。可重试的断线或组件卸载会释放尚未完成的恢复实例,旧回调不能替换新连接的视图。只使用 xterm 公开 API,不使用同时丢弃应答和用户输入的全局标志。

## 验证与验收

- Registry:实时与快照字节不变;只应答 primary DA。
- 路由:快照元数据先于二进制历史;区分首次创建与重连;旧客户端明确失败且不 spawn。
- 浏览器:回放没有向外应答,排队的实时颜色和模式查询能应答,回放期间键盘输入仍到达 stdin。首次查询有效;primary DA 只有一个应答方;中断的回放不能替换当前视图。
- 实现完成后运行定向测试和真实浏览器中的组件检查。原生 Windows 进程计数、DLL 回退加载和交互渲染需要单独的 Windows 验收。浏览器证据不等于原始原生泄漏已经修复。
22 changes: 22 additions & 0 deletions packages/cli/src/agent-view/pty-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,28 @@ describe('launchAgentViewPtyHost', () => {
expect(pty.process.killCalls).toEqual([undefined, undefined]);
});

it('spawns the worker with the bundled ConPTY backend on Windows', async () => {
// The inbox backend orphans a `conhost.exe --headless` per natural worker
// exit (microsoft/node-pty#965); Windows workers must spawn with the
// bundled backend, which releases its host reference right after spawn.
const pty = createFakePty();
const original = process.platform;
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
});
try {
await launchAgentViewPtyHost(createLaunch(), { pty });
} finally {
Object.defineProperty(process, 'platform', {
value: original,
configurable: true,
});
}

expect(pty.spawnCalls[0]?.options.useConptyDll).toBe(true);
});

it('resets the input decoder between attach sessions', async () => {
const pty = createFakePty();
const handle = await launchAgentViewPtyHost(createLaunch(), { pty });
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/agent-view/pty-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface AgentViewPtySpawnOptions {
rows: number;
env: Record<string, string>;
handleFlowControl: boolean;
useConptyDll: boolean;
}

export interface AgentViewPtyDisposable {
Expand Down Expand Up @@ -349,6 +350,10 @@ export async function launchAgentViewPtyHost(
rows: launch.terminal.rows,
env: workerEnv,
handleFlowControl: false,
// Windows: the inbox ConPTY backend orphans a `conhost.exe --headless`
// per natural worker exit (microsoft/node-pty#965); the bundled backend
// releases its host reference right after spawn. Mirrors #11497 / #11352.
useConptyDll: process.platform === 'win32',
});
let inputDecoder = new StringDecoder('utf8');

Expand Down
25 changes: 23 additions & 2 deletions packages/cli/src/serve/routes/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const context = {
const resolveWorkspace = (selector: string) =>
selector === '/workspace' ? context : undefined;
const request = {
url: '/terminal?terminalId=terminal%3Amanual-1&cwd=%2Fworkspace',
url: '/terminal?terminalId=terminal%3Amanual-1&cwd=%2Fworkspace&replay=1',
} as IncomingMessage;

class FakeWebSocket extends EventEmitter {
Expand All @@ -45,6 +45,7 @@ function registryWithSnapshot(
exited: boolean;
exitCode?: number;
workspaceCwd: string;
handlesPrimaryDa?: boolean;
}
| undefined,
) {
Expand All @@ -61,6 +62,18 @@ function registryWithSnapshot(
}

describe('terminal WebSocket route', () => {
it('rejects legacy replay clients before creating a PTY', async () => {
const registry = registryWithSnapshot(undefined);
const ws = new FakeWebSocket();
await createTerminalWsHandler(registry, resolveWorkspace).onConnection(
ws as unknown as WebSocket,
{ url: request.url!.replace('&replay=1', '') } as IncomingMessage,
);
expect(ws.close).toHaveBeenCalledWith(4002, 'Terminal protocol mismatch');
expect(registry.create).not.toHaveBeenCalled();
expect(registry.addOutputListener).not.toHaveBeenCalled();
});

it('rejects invalid workspaces and terminal ids before creating a PTY', async () => {
const registry = registryWithSnapshot(undefined);
const unknown = new FakeWebSocket();
Expand Down Expand Up @@ -119,6 +132,9 @@ describe('terminal WebSocket route', () => {
env: { PATH: '/runtime/bin' },
});
expect(sentOutput(ws)).toContain('prompt $ ');
expect(ws.sent[0]).toBe(
'\x00{"type":"snapshot","replay":false,"handlesPrimaryDa":false}',
);
expect(registry.write).toHaveBeenCalledWith(
'terminal:manual-1',
'echo ready\r',
Expand Down Expand Up @@ -320,6 +336,7 @@ describe('terminal WebSocket route', () => {
output: '',
exited: false,
workspaceCwd: '/workspace',
handlesPrimaryDa: true,
});
vi.mocked(registry.addOutputListener).mockImplementation(
(_terminalId, listener) => {
Expand All @@ -343,6 +360,10 @@ describe('terminal WebSocket route', () => {

expect(sentOutput(first)).toContain('live');
expect(sentOutput(second)).toContain('live');
expect(first.sent.slice(0, 2)).toEqual([
'\x00{"type":"snapshot","replay":true,"handlesPrimaryDa":true}',
Buffer.from(''),
]);
expect(registry.create).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -429,7 +450,7 @@ describe('terminal WebSocket route', () => {
await createTerminalWsHandler(registry, resolveWorkspace).onConnection(
ws as unknown as WebSocket,
{
url: `/terminal?terminalId=${terminalId}&cwd=%2Fworkspace`,
url: `/terminal?terminalId=${terminalId}&cwd=%2Fworkspace&replay=1`,
} as IncomingMessage,
);

Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/serve/routes/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ export function createTerminalWsHandler(
ws.close(4004, 'Terminal released');
return;
}
if (url.searchParams.get('replay') !== '1') {
sendControl(ws, {
type: 'error',
message: 'Terminal protocol changed; reload this page.',
});
ws.close(4002, 'Terminal protocol mismatch');
return;
}
const workspaceSelector = selector;

let created = false;
Expand Down Expand Up @@ -328,6 +336,11 @@ export function createTerminalWsHandler(
ws.off('error', markClosed);
ws.on('error', cleanup);
if (!ensureWorkspaceAvailable()) return;
sendControl(ws, {
type: 'snapshot',
replay: !created,
handlesPrimaryDa: snapshot.handlesPrimaryDa === true,
});
if (!sendOutput(ws, snapshot.output)) {
cleanup();
ws.close(1013, 'Terminal output backpressure');
Expand Down
Loading
Loading