terminal: keep the pty alive while its tab is open, whatever the view does

The close frame was sent on every unmount, so switching conversations (or
refreshing) killed the shell even though the tab was still open; coming
back spawned a fresh terminal. Now the unmount close frame only fires when
the tab is truly gone (checked against the per-session store state), so a
bare unmount hands the process to the host's reconnect grace and the next
connect reattaches the SAME shell with its transcript. Closing a tab
releases the quota immediately: the socket-open path keeps the close
frame, and a new pty.close API covers the socket-down (reconnect loop)
case that previously left a 30s zombie holding the quota.

The host also records the spawn cwd and respawns when a reconnect carries
a different authoritative cwd — the page-load hydrate race can attach the
real session cwd after the first connect fell back to the process cwd, and
a shell in the wrong directory must not linger.
This commit is contained in:
Menghuan1918
2026-08-08 17:24:08 +08:00
parent cef0251aba
commit d1cdca3324
8 changed files with 131 additions and 17 deletions
+13 -2
View File
@@ -140,7 +140,18 @@ export function Sidebar(props: { ctx: Context }) {
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => { sidebarStore.reduce(s => closeTab(s, paneId, tabId)) },
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
const current = sidebarStore.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
sidebarStore.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
},
activateTab: (paneId, tabId) => {
sidebarStore.reduce(s => ({
...s,
@@ -167,7 +178,7 @@ export function Sidebar(props: { ctx: Context }) {
resizeSplit: (splitId, index, deltaFrac) => {
sidebarStore.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
},
}), [])
}), [sessionId, cwd])
if (state === undefined || sessionId === undefined) {
return (
+9 -4
View File
@@ -14,6 +14,7 @@ import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import { sidebarStore } from './state.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
@@ -125,10 +126,14 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
window.clearTimeout(retry)
observer.disconnect()
inputSub.dispose()
// Tell the host the owning tab is gone so it releases the terminal
// quota immediately (best effort; a dropped socket gets the grace
// period on the host side instead).
if (socket !== null && socket.readyState === WebSocket.OPEN) {
// The close frame tells the host the owning tab is GONE (immediate
// quota release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
if (!sidebarStore.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
}
socket?.close()
+5
View File
@@ -114,6 +114,11 @@ export const api = {
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
}
/** Absolute URL of the media route for one path (images only). */
+20
View File
@@ -125,6 +125,11 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state tree (any pane). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
@@ -506,6 +511,21 @@ export class SidebarStore {
this.notify()
}
/**
* Whether a tab still exists in its session's state. Views use this on
* unmount to tell "the tab was closed" (release the terminal now) from
* "the tree re-rendered / the conversation switched" (the tab is still
* open — keep the terminal alive through the host's reconnect grace).
* Checks the session's own map entry (the current snapshot may already
* point at another session when a conversation switch unmounts the old
* one's tabs).
*/
tabOpen(sessionId: string, tabId: string): boolean {
const state = this.bySession.get(sessionId)
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
return state !== undefined && tabOpenIn(state, tabId)
}
/** Apply a pure reducer (returns the next state). */
reduce(reducer: (state: SidebarState) => SidebarState): void {
const sessionId = this.snapshot.sessionId