-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(core): free an exited web terminal's PTY resources at exit time #11572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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)); | ||
| }; | ||
| let dataDisposable: { dispose(): void } | undefined; | ||
| let exitDisposable: { dispose(): void } | undefined; | ||
|
|
@@ -347,6 +371,7 @@ export class WebTerminalRegistry { | |
| exitListeners: new Set(), | ||
| dataDisposable, | ||
| exitDisposable, | ||
| ptyResourcesReleased: false, | ||
| }; | ||
| sessionRef.current = session; | ||
| this.sessions.set(terminalId, session); | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Collapsing Witness: Please retarget these in the same change. In One constraint on the rewrite: the new exit-time site must stay in 中文说明把 证据(逐行读取核对,未采信引用): 请在同一次改动里把这些引用改到当前的控制流上。在 改写时有一条约束:这个新的退出时调用点必须留在 — 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); | ||
|
|
||
There was a problem hiding this comment.
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.tsis the only test that exercises the real registry, becausepackages/cli/src/serve/routes/terminal.test.ts:51-60builds a stub (} as unknown as WebTerminalRegistry;).Witness:
The fix is a test that emits data after
onExitand asserts it still reachesreadSnapshot(...).output. It only pins anything if the harness's data disposable actually detaches — todaydisposeDatais a barevi.fn()(web-terminal-registry.test.ts:53,:72), soonData(...)still reacheshandleDataafter "disposal" and the assertion passes on both the intact and the inlined arm: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;)。证据(本次评审实测):
修复方式是补一个测试:在
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)