fix(terminal): prevent WebGL context leak and atomic generation bump (#806)

This commit is contained in:
Jinwoo Hong 2026-04-18 19:35:26 -04:00 committed by GitHub
parent 3918e4cfcf
commit e8b8b5f96e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 31 additions and 20 deletions

View file

@ -34,7 +34,14 @@ export function useTerminalPaneGlobalEffects({
isVisibleRef,
toggleExpandPane
}: UseTerminalPaneGlobalEffectsArgs): void {
const wasVisibleRef = useRef(false)
// Why: starts as `true` so the first render with isVisible=false triggers
// suspendRendering(). Without this, background worktrees that mount hidden
// (isVisible=false from the start) never suspend their WebGL contexts —
// openTerminal() unconditionally creates a WebGL addon, but this effect
// only suspends on true→false transitions. The leaked contexts exhaust
// Chromium's ~8-context budget, causing "webglcontextlost" on visible
// terminals and making them unresponsive.
const wasVisibleRef = useRef(true)
// Why: tracks any in-progress chunked pending-write flush so the cleanup
// function can cancel it if the pane deactivates mid-flush.

View file

@ -529,6 +529,27 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// side-effects limited to unread clearing; true activity signals such as
// PTY lifecycle and explicit edits still flow through bumpWorktreeActivity.
const metaUpdates: Partial<WorktreeMeta> = shouldClearUnread ? { isUnread: false } : {}
// Why: the generation bump for dead-PTY tabs MUST happen in the same
// set() as the activation. Two separate set() calls let React/Zustand
// render the old (dead-transport) TerminalPane as visible for one frame
// before the generation bump unmounts it — that intermediate render
// resumes the pane with a transport stuck at connected=false/ptyId=null,
// and user input is silently dropped.
const tabs = s.tabsByWorktree[worktreeId ?? ''] ?? []
const allDead = worktreeId && tabs.length > 0 && tabs.every((tab) => !tab.ptyId)
const tabsByWorktreeUpdate = allDead
? {
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId!]: tabs.map((tab) => ({
...tab,
generation: (tab.generation ?? 0) + 1
}))
}
}
: {}
return {
activeWorktreeId: worktreeId,
activeFileId,
@ -538,28 +559,11 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
activeTabId,
...(shouldClearUnread
? { worktreesByRepo: applyWorktreeUpdates(s.worktreesByRepo, worktreeId, metaUpdates) }
: {})
: {}),
...tabsByWorktreeUpdate
}
})
// If the worktree has tabs but all PTYs are dead (e.g. after shutdown),
// bump generation so TerminalPanes remount with fresh PTY connections.
if (worktreeId) {
const tabs = get().tabsByWorktree[worktreeId] ?? []
const allDead = tabs.length > 0 && tabs.every((tab) => !tab.ptyId)
if (allDead) {
set((s) => ({
tabsByWorktree: {
...s.tabsByWorktree,
[worktreeId]: (s.tabsByWorktree[worktreeId] ?? []).map((tab) => ({
...tab,
generation: (tab.generation ?? 0) + 1
}))
}
}))
}
}
// Why: force-refreshing GitHub data on every switch burned API rate limit
// quota and added 200-800ms latency. Only refresh when cache is actually
// stale (>5 min old). Users can still force-refresh via the sidebar button.