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
77 changes: 77 additions & 0 deletions packages/core/src/services/web-terminal-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,83 @@ describe('WebTerminalRegistry', () => {
expect(kill).not.toHaveBeenCalled();
});

it('frees an exited session at exit time, not at the idle reclaim', async () => {
osPlatform.mockReturnValue('win32');
const registry = new WebTerminalRegistry();
await registry.create({
terminalId: 'terminal:exit-time-release',
workspaceCwd: '/workspace',
});

onExit({ exitCode: 0 });
// One turn of the event loop, on real timers: the 15-minute idle reclaim
// cannot have run, and nothing below calls release(). A live exit closes
// the route's socket with 4000, which the client treats as non-retryable,
// so no tab close follows either — that window is what #11353 is about.
await new Promise<void>((resolve) => setImmediate(resolve));

// node-pty strands its conout worker on a natural exit, so that worker is
// the resource an exited web terminal held for up to IDLE_RECLAIM_MS — and
// exited sessions do not count against the admission cap, so accumulation
// inside the window was unbounded. See #11303 / #11353.
expect(conoutDispose).toHaveBeenCalledOnce();
expect(disposeData).toHaveBeenCalledOnce();
expect(disposeExit).toHaveBeenCalledOnce();
// Nothing may signal an exited shell's possibly-recycled pid.
expect(kill).not.toHaveBeenCalled();
expect(spawnSync).not.toHaveBeenCalled();
// The session itself survives the release, for scrollback replay.
expect(registry.readSnapshot('terminal:exit-time-release')).toBeDefined();
});

it('still replays buffered scrollback after the exit-time release', async () => {
osPlatform.mockReturnValue('win32');
const registry = new WebTerminalRegistry();
await registry.create({
terminalId: 'terminal:replay-after-exit-release',
workspaceCwd: '/workspace',
});

onData('boot\r\n');
onExit({ exitCode: 3 });
await new Promise<void>((resolve) => setImmediate(resolve));

// The exit-time release frees PTY handles only. The session and its buffer
// stay in the map, so a second tab attaching to this terminal id still gets
// the scrollback plus the exit state — which is what terminal.ts's
// releaseAfterReplay path depends on.
expect(conoutDispose).toHaveBeenCalledOnce();
expect(registry.readSnapshot('terminal:replay-after-exit-release')).toEqual(
{
output: 'boot\r\n',
exited: true,
exitCode: 3,
workspaceCwd: '/workspace',
},
);
});

it('does not free an exited session twice when release follows', async () => {
osPlatform.mockReturnValue('win32');
const registry = new WebTerminalRegistry();
await registry.create({
terminalId: 'terminal:exit-release-once',
workspaceCwd: '/workspace',
});

onExit({ exitCode: 0 });
await new Promise<void>((resolve) => setImmediate(resolve));
// The tab close, a workspace drain, dispose() or the reclaim all still run
// release() on a session whose PTY was already freed at exit time.
expect(registry.release('terminal:exit-release-once')).toBe(true);

expect(conoutDispose).toHaveBeenCalledOnce();
expect(disposeData).toHaveBeenCalledOnce();
expect(disposeExit).toHaveBeenCalledOnce();
expect(kill).not.toHaveBeenCalled();
expect(spawnSync).not.toHaveBeenCalled();
});

it('forwards live output and bounds unacknowledged PTY input', async () => {
const registry = new WebTerminalRegistry();
await registry.create({
Expand Down
79 changes: 61 additions & 18 deletions packages/core/src/services/web-terminal-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ interface PtySession {
reclaimTimer?: ReturnType<typeof setTimeout>;
dataDisposable?: { dispose(): void };
exitDisposable?: { dispose(): void };
/**
* Set once the PTY-side resources above have been freed. The exit-time
* release frees them while the session stays in the map for scrollback
* replay, so a later `release()` — tab close, workspace drain, `dispose()`,
* idle reclaim — must not free them a second time. See #11353.
*/
ptyResourcesReleased: boolean;
}

interface SpawnedWebTerminalPty extends WebTerminalPty {
Expand Down Expand Up @@ -270,6 +277,23 @@ export class WebTerminalRegistry {
session.exited = true;
session.exitCode = e.exitCode;
for (const listener of [...session.exitListeners]) listener(e);
// Nothing needs the PTY once the shell is gone: write() and resize()
// already short-circuit on `exited`, and readSnapshot() replays the
// JS-side `buffer`, not the console. Waiting for release() instead left
// every exited web terminal holding node-pty's conout worker — and,
// upstream, its conhost.exe — for up to IDLE_RECLAIM_MS, because the
// route keeps the session alive for scrollback and the client treats the
// 4000 close as non-retryable, so only a tab close releases it. Exited
// sessions also do not count against the admission cap, so accumulation
// inside that window was unbounded. See #11303 / #11353.
//
// Deferred one turn rather than run inline: onExit can arrive slightly
// before late PTY data is processed, the same race shellExecutionService
// drains before finalizing. setImmediate runs after the poll-phase
// callbacks already queued this tick, so trailing output still reaches
// `buffer` before the data listener is detached. handleData is fully
// synchronous, so one turn is enough — there is no chain to flush.
setImmediate(() => this.releasePtyResources(session));
Comment on lines +295 to +296

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Nothing in the suite distinguishes this deferred release from an inline one, so the one-turn deferral that the six-line comment above it exists to justify is unenforced. Replacing this line with a direct this.releasePtyResources(session); leaves all 32 tests passing, which means a later simplification pass can inline the async hop — the shorter, more obvious code — and silently drop the trailing-output protection that comment argues for, with the suite, the typecheck and lint all staying green. No other suite can catch it either: web-terminal-registry.test.ts is the only test that exercises the real registry, because packages/cli/src/serve/routes/terminal.test.ts:51-60 builds a stub (} as unknown as WebTerminalRegistry;).

Witness:

INTACT (setImmediate(() => this.releasePtyResources(session));) : Tests 32 passed (32)
MUTANT (this.releasePtyResources(session);)                     : Tests 32 passed (32)

flip-check, against a harness whose disposable really detaches:
  MUTANT: x  late data still reaches readSnapshot()
           - "output": "boot\r\nLATE-TRAILING\r\n"
           + "output": "boot\r\n"
  INTACT: 2 passed   (tree restored, diff -q-identical to 88718ce9)

The fix is a test that emits data after onExit and asserts it still reaches readSnapshot(...).output. It only pins anything if the harness's data disposable actually detaches — today disposeData is a bare vi.fn() (web-terminal-registry.test.ts:53, :72), so onData(...) still reaches handleData after "disposal" and the assertion passes on both the intact and the inlined arm:

onData: vi.fn((listener) => {
  onData = listener;
  return {
    dispose: () => {
      onData = () => {};
      disposeData();
    },
  };
}),

Then assert on the buffered output rather than on a spy call count.

That new case must go red when setImmediate(() => this.releasePtyResources(session)); is replaced by a direct call — please confirm the mutation when you add it, because with the shipped non-detaching harness the same assertion passes on both arms and pins nothing.

中文说明

套件里没有任何测试能区分「延后一轮释放」与「同步立即释放」,因此上方那段六行注释所要论证的延后本身并没有被钉住。把这一行换成直接调用 this.releasePtyResources(session);,32 个测试依然全部通过;这意味着后来一次「简化」改动完全可以把这个异步跳转内联掉(那是更短、更直观的写法),并悄无声息地移除该注释所论证的尾部输出保护,而测试套件、typecheck 与 lint 全绿。也没有别的套件能抓到它:web-terminal-registry.test.ts 是唯一真正驱动 registry 的测试,因为 packages/cli/src/serve/routes/terminal.test.ts:51-60 构造的是桩对象(} as unknown as WebTerminalRegistry;)。

证据(本次评审实测):

原始代码 (setImmediate(() => this.releasePtyResources(session));) : Tests 32 passed (32)
变异体   (this.releasePtyResources(session);)                     : Tests 32 passed (32)

翻转校验,使用真正会摘除监听器的脚手架:
  变异体: x  退出后投递的数据仍能进入 readSnapshot()
           - "output": "boot\r\nLATE-TRAILING\r\n"
           + "output": "boot\r\n"
  原始代码: 2 passed   (文件已还原,diff -q 与 88718ce9 一致)

修复方式是补一个测试:在 onExit 之后投递数据,并断言它仍然出现在 readSnapshot(...).output 里。但只有在测试脚手架的 data disposable 真正摘除监听器时它才有约束力——目前 disposeData 只是一个裸 vi.fn()web-terminal-registry.test.ts:53:72),所以「dispose」之后 onData(...) 仍会进入 handleData,该断言在原始代码和内联变异体上都会通过。请让脚手架建模真实的摘除行为,然后断言缓冲区里的输出,而不是断言 spy 的调用次数。

新测试必须在把 setImmediate(() => this.releasePtyResources(session)); 换成直接调用时变红——添加时请顺手验证这个变异,因为在现有不摘除监听器的脚手架下,同一断言两边都通过,什么也钉不住。

— qwen3.8-max via Qwen Code /review (v0.23.2)

};
let dataDisposable: { dispose(): void } | undefined;
let exitDisposable: { dispose(): void } | undefined;
Expand Down Expand Up @@ -347,6 +371,7 @@ export class WebTerminalRegistry {
exitListeners: new Set(),
dataDisposable,
exitDisposable,
ptyResourcesReleased: false,
};
sessionRef.current = session;
this.sessions.set(terminalId, session);
Expand Down Expand Up @@ -458,29 +483,20 @@ export class WebTerminalRegistry {
listener({ exitCode: 143, signal: 15 });
}
}
session.dataDisposable?.dispose();
session.exitDisposable?.dispose();
session.outputListeners.clear();
session.exitListeners.clear();
if (!session.exited) {
// killPtyTree has to run before releasePtyResources: its pty.kill()
// defers the whole teardown while `_isReady` is false, so a terminal
// released before its shell's first output byte (tab closed during slow
// pwsh startup, or a workspace drain) still has a kill() queued in
// node-pty's `_deferreds`. The wrapper's kill() notes the close only when
// it really ran; releaseHost then disposes the worker a deferred kill
// would strand, and skips the native close so the queued kill() stays the
// single closer — never a second close.
killPtyTree(session.pty);
// killPtyTree's pty.kill() defers its whole teardown while `_isReady` is
// false, so a terminal released before its shell's first output byte (tab
// closed during slow pwsh startup, or a workspace drain) still has a
// kill() queued in node-pty's `_deferreds`. The wrapper's kill() notes
// the close only when it really ran; releaseHost then disposes the worker
// a deferred kill would strand, and skips the native close so the queued
// kill() stays the single closer — never a second close.
session.pty.releaseHost?.();
} else {
// The shell already exited, so nothing may signal its (possibly recycled)
// pid — but node-pty does not release its conout worker thread on a
// natural exit, so without this every terminal the user exits leaks one
// for the life of the CLI. Same defect as the shell-tool path in
// shellExecutionService. The conhost.exe half is not freed here (the
// native baton is already gone); see releaseConPtyHost. See #11303.
session.pty.releaseHost?.();
}
this.releasePtyResources(session);
return true;
}

Expand All @@ -502,6 +518,33 @@ export class WebTerminalRegistry {
this.cancelledCreations.delete(terminalId);
}

/**
* Free a session's PTY-side resources exactly once: detach the data/exit
* listeners, then release the ConPTY host / conout worker that node-pty
* strands on a natural exit. Without the second half every terminal the user
* exits leaks a worker for the life of the CLI — the same defect the
* shell-tool path has. The conhost.exe half is not freed on that path (the
* native baton is already gone); see releaseConPtyHost. See #11303.
*
* Deliberately leaves the session's map entry and its `buffer` alone, and
* never signals the pid: on the exited path the shell is gone and its pid may
* be recycled, which is why #11313 added `releaseHost` instead of reusing
* `kill()`. Keeping the entry is what lets `readSnapshot()` still replay the
* scrollback after an exit-time release.
*
* Called from `handleExit` (deferred one turn, so an exited web terminal
* stops holding the worker for the whole idle-reclaim window — #11353) and
* from `release()` on both of its arms, where the flag keeps a release that
Comment on lines +535 to +537

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Collapsing release()'s two arms into one unconditional helper call deletes the else arm that the releaseHost call-site inventory in packages/core/src/services/conpty-host.ts still names as "the primary web-terminal path for #11303" — and that inventory is exactly the checklist conpty-host.ts:19-20 tells a node-pty bump to re-walk ("A bump therefore has to be re-checked against src/win/conpty.cc, not only against the JS shape"). The next maintainer bumping @lydell/node-pty walks that list, looks for release()'s else arm in this file, cannot find it, and then either treats the paragraph as describing dead code or never evaluates the site that now handles every natural exit — the one caller that reaches releaseHost's _isReady === false branch with no queued kill() behind it. So a native-semantics change on the post-onExit path, such as the std::erase_if baton change conpty-host.ts:18-21 already flags, ships unreviewed for the web terminal's main path.

Witness:

sweep: 7 sites across 3 files describe release()'s arm shape; 2 outright falsified
oracle: post-diff release() read at web-terminal-registry.ts:488-499
          if (!session.exited) { ... killPtyTree(session.pty); }
          this.releasePtyResources(session);   <- unconditional, no else
        vs the base if/else at :456-484
  conpty-host.ts:135                 "`else` arm"                              deleted construct
  web-terminal-registry.test.ts:367  "the whole point of the else branch"      deleted construct
  conpty-host.ts:136                 "the primary web-terminal path for #11303" caller mislabelled
  web-terminal-registry.test.ts:321  "release() calls ...releaseHost?.() bare"  now indirect
  web-terminal-registry.ts:332-334   "reaches it from BOTH arms"                vocabulary drift
  conpty-host.ts:144                 "from the live arm AND the exited arm"     vocabulary drift
  web-terminal-registry.ts:537       "from release() on both of its arms"       drift (this line)

Please retarget these in the same change. In conpty-host.ts:134-136 and :142-146, replace "release()'s else arm" / "the exited arm" with the exit-time path (handleExit → deferred releasePtyResourcesreleaseHost), keeping release() listed as the secondary caller. At web-terminal-registry.ts:332-334, say releaseHost is reached from releasePtyResources, which handleExit (deferred) and release() both call under the ptyResourcesReleased flag. And drop the release()-arm references at web-terminal-registry.test.ts:355 and :367. This JSDoc already names handleExit as a caller, so only its "both of its arms" phrasing needs the same touch.

One constraint on the rewrite: the new exit-time site must stay in conpty-host.ts's native no-op group rather than its close group, per conpty-host.ts:128-131 — "In src/win/conpty.cc the native exit-watcher thread erases the pty baton before it delivers the JS onExit, and PtyKill skips ClosePseudoConsole when get_pty_baton returns null" — and the exit-time release runs strictly after onExit. The rewritten enumeration must therefore keep conpty-host.ts:147-148 ("The inbox conhost half of #11303 is therefore not fixed by this function on the natural-exit path") true of it.

中文说明

release() 的两个分支收敛成一次无条件的辅助函数调用,删掉了那个 else 分支;而 packages/core/src/services/conpty-host.ts 里的 releaseHost 调用点清单至今仍把它称作「#11303 的 web-terminal 主路径」——而那份清单正是 conpty-host.ts:19-20 要求升级 node-pty 时必须重新走一遍的检查表(「A bump therefore has to be re-checked against src/win/conpty.cc, not only against the JS shape」)。下一位升级 @lydell/node-pty 的维护者照着那份清单来找本文件里 release()else 分支,找不到,于是要么把那段话当成在描述已死的代码,要么根本没有评估那个如今承接每一次自然退出的调用点——也就是唯一一个在身后没有排队 kill() 的情况下进入 releaseHost_isReady === false 分支的调用者。这样一来,onExit 之后那条路径上的原生语义变化(例如 conpty-host.ts:18-21 已经点出的 std::erase_if baton 改动)就会在 web terminal 的主路径上未经复核地随升级发布。

证据(逐行读取核对,未采信引用):

扫描:3 个文件中共 7 处描述 release() 的分支形态;其中 2 处被彻底证伪
判据:改动后的 release(),读自 web-terminal-registry.ts:488-499
          if (!session.exited) { ... killPtyTree(session.pty); }
          this.releasePtyResources(session);   <- 无条件调用,没有 else
        对比 base 上 :456-484 的 if/else
  conpty-host.ts:135                 「`else` arm」                             已删除的结构
  web-terminal-registry.test.ts:367  「the whole point of the else branch」     已删除的结构
  conpty-host.ts:136                 「the primary web-terminal path for #11303」调用者标注错误
  web-terminal-registry.test.ts:321  「release() calls ...releaseHost?.() bare」 现已是间接调用
  web-terminal-registry.ts:332-334   「reaches it from BOTH arms」               措辞过时
  conpty-host.ts:144                 「from the live arm AND the exited arm」    措辞过时
  web-terminal-registry.ts:537       「from release() on both of its arms」      措辞过时(即本行)

请在同一次改动里把这些引用改到当前的控制流上。在 conpty-host.ts:134-136:142-146,把「release()else 分支」/「exited 分支」替换为退出时路径(handleExit → 延后的 releasePtyResourcesreleaseHost),并把 release() 保留为次要调用者。在 web-terminal-registry.ts:332-334,说明 releaseHost 是由 releasePtyResources 到达的,而 handleExit(延后)与 release() 都在 ptyResourcesReleased 标志保护下调用它。同时删去 web-terminal-registry.test.ts:355:367release() 分支的引用。本段 JSDoc 已经点名 handleExit 是调用者,因此只有「both of its arms」这一措辞需要同样处理。

改写时有一条约束:这个新的退出时调用点必须留在 conpty-host.ts原生空操作那一组,而不是关闭那一组,依据是 conpty-host.ts:128-131——「In src/win/conpty.cc the native exit-watcher thread erases the pty baton before it delivers the JS onExit, and PtyKill skips ClosePseudoConsole when get_pty_baton returns null」——而退出时释放严格发生在 onExit 之后。因此改写后的清单必须让 conpty-host.ts:147-148(「The inbox conhost half of #11303 is therefore not fixed by this function on the natural-exit path」)对它依然成立。

— qwen3.8-max via Qwen Code /review (v0.23.2)

* follows an exit-time release from disposing anything twice.
*/
private releasePtyResources(session: PtySession): void {
if (session.ptyResourcesReleased) return;
session.ptyResourcesReleased = true;
session.dataDisposable?.dispose();
session.exitDisposable?.dispose();
session.pty.releaseHost?.();
}

private clearReclaim(session: PtySession): void {
if (session.reclaimTimer) {
clearTimeout(session.reclaimTimer);
Expand Down
Loading