Merge commit '36cc8495efecb7cb599a541d69bed6abe1b184b5' into brennanb2025/fix-10385-false-connected

This commit is contained in:
Brennan Benson
2026-08-01 14:00:39 -07:00
21 changed files with 2598 additions and 245 deletions
+44
View File
@@ -0,0 +1,44 @@
import { basename, win32 } from 'node:path'
export function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
export function hasMatchingOuterQuotes(value: string): boolean {
const trimmed = value.trim()
const quote = trimmed[0]
return (quote === '"' || quote === "'") && trimmed.endsWith(quote)
}
function getLeadingShellCommandToken(command: string): string {
const trimmed = command.trim()
const quote = trimmed[0]
if (quote === '"' || quote === "'") {
const closingIndex = trimmed.indexOf(quote, 1)
if (closingIndex > 0) {
return trimmed.slice(1, closingIndex)
}
}
return trimmed.split(/\s+/, 1)[0] ?? ''
}
/** Lowercased launcher name without directory or Windows executable extension. */
export function getLauncherBaseName(
command: string,
options: { shellCommand?: boolean } = {}
): string {
const normalized = options.shellCommand
? getLeadingShellCommandToken(command)
: stripMatchingQuotes(command)
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
}
export function isWindowsBatchLauncher(command: string): boolean {
return /\.(?:cmd|bat)$/i.test(stripMatchingQuotes(command))
}
+176 -1
View File
@@ -32,6 +32,168 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => {
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'idea' ? `${installBin}\\idea.cmd` : command
)
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\idea64.exe`,
spawnArgs: ['C:\\workspaces\\orca']
})
// Why: a bare PATH `idea64` may belong to a different, stale install.
expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' })
expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything())
})
it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => {
const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'goland64' ? `${installBin}\\goland64.cmd` : command
)
expect(
resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\goland64.exe`
}).spawnCmd
).toBe(`${installBin}\\goland64.exe`)
})
it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => {
const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
resolveCliCommandMock.mockImplementation((command: string) =>
command === 'idea' ? toolboxShim : command
)
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
detachedGui: true,
spawnCmd: toolboxShim,
spawnArgs: ['C:\\workspaces\\orca']
})
})
it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => {
expect(
resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
detachedGui: true,
spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat',
spawnArgs: ['C:\\ws']
})
})
it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => {
const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin'
expect(
resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\webstorm64.exe`,
spawnArgs: ['C:\\ws']
})
})
it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => {
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
expect(
resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', {
platform: 'win32',
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: `${installBin}\\idea64.exe`,
spawnArgs: ['C:\\ws']
})
})
it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => {
const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe'
expect(
resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => true
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: guiExe,
spawnArgs: ['C:\\ws']
})
})
it('does not rewrite non-JetBrains .exe launchers via colocation', () => {
const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe'
expect(
resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => true
}).spawnCmd
).toBe(codeExe)
})
it('keeps idea.exe when no colocated idea64.exe exists', () => {
const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe'
expect(
resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', {
platform: 'win32',
fileExists: () => false
})
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: consoleExe,
spawnArgs: ['C:\\ws']
})
})
// Why: `start` re-parses argv, so only JetBrains shims may take that path.
it.each([
['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']],
[
'cursor',
'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd',
['--new-window', 'C:\\workspaces\\orca']
]
])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => {
resolveCliCommandMock.mockImplementation(() => resolvedCommand)
expect(
resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' })
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: resolvedCommand,
spawnArgs
})
})
it('appends escaped paths to compound macOS open commands', () => {
expect(
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
@@ -85,6 +247,19 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => {
expect(
resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', {
platform: 'win32'
})
).toEqual({
kind: 'shell',
hideWindowsConsole: true,
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', 'code --reuse-window C:\\workspaces\\orca']
})
})
it('quotes Windows paths with spaces in compound commands', () => {
expect(
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
@@ -245,7 +420,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
).toEqual([pathValue])
})
it('does not rewrite compound VS Code commands', () => {
it('does not rewrite compound VS Code commands into WSL remote args', () => {
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
expect(
+140 -63
View File
@@ -1,26 +1,46 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { basename, posix, win32 } from 'node:path'
import { posix, win32 } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher'
import { resolveCliCommand } from './codex-cli/command'
import { getCmdExePath } from './win32-utils'
import {
getLauncherBaseName,
hasMatchingOuterQuotes,
stripMatchingQuotes
} from './editor-launcher-name'
import {
isJetBrainsConsoleShim,
resolveColocatedJetBrainsGuiExecutable
} from './jetbrains-windows-gui-launchers'
import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
export type ExternalEditorExecutableLaunchSpec = {
kind: 'executable'
hideWindowsConsole: boolean
/**
* Set only for Windows batch shims that would otherwise leave a Command
* Prompt behind; routes the spawn through `start` with an empty title and
* `/B`. Left unset elsewhere because `start` re-parses quoted argv.
*/
detachedGui?: boolean
spawnCmd: string
spawnArgs: string[]
}
export type ExternalEditorShellLaunchSpec = {
kind: 'shell'
hideWindowsConsole: boolean
spawnCmd: string
spawnArgs: string[]
}
export type ExternalEditorLaunchSpec =
| {
kind: 'executable'
hideWindowsConsole: boolean
spawnCmd: string
spawnArgs: string[]
}
| {
kind: 'shell'
hideWindowsConsole: boolean
spawnCmd: string
spawnArgs: string[]
}
| ExternalEditorExecutableLaunchSpec
| ExternalEditorShellLaunchSpec
function escapePosixPathForShell(pathValue: string): string {
if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) {
@@ -39,41 +59,6 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin
: escapePosixPathForShell(pathValue)
}
function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string {
const normalized = options.shellCommand
? getLeadingShellCommandToken(command)
: stripMatchingQuotes(command)
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
}
function getLeadingShellCommandToken(command: string): string {
const trimmed = command.trim()
const quote = trimmed[0]
if (quote === '"' || quote === "'") {
const closingIndex = trimmed.indexOf(quote, 1)
if (closingIndex > 0) {
return trimmed.slice(1, closingIndex)
}
}
return trimmed.split(/\s+/, 1)[0] ?? ''
}
function stripMatchingQuotes(value: string): string {
const trimmed = value.trim()
const quote = trimmed[0]
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
return trimmed.slice(1, -1)
}
return trimmed
}
function hasMatchingOuterQuotes(value: string): boolean {
const trimmed = value.trim()
const quote = trimmed[0]
return (quote === '"' || quote === "'") && trimmed.endsWith(quote)
}
function isWindowsExecutablePath(command: string): boolean {
return win32.isAbsolute(command) && /\.(?:cmd|exe|bat|com)$/i.test(command)
}
@@ -133,6 +118,46 @@ function isCompoundShellCommand(command: string): boolean {
return /\s/.test(command)
}
function preferJetBrainsGuiExecutable(
editorCommand: string,
platform: NodeJS.Platform,
fileExists: (path: string) => boolean
): string {
if (platform !== 'win32') {
return editorCommand
}
return resolveColocatedJetBrainsGuiExecutable(editorCommand, fileExists) ?? editorCommand
}
function resolveSimpleEditorCommand(
command: string,
platform: NodeJS.Platform,
fileExists: (path: string) => boolean
): string {
return preferJetBrainsGuiExecutable(
resolveCliCommand(command, { platform }),
platform,
fileExists
)
}
function buildExecutableLaunchSpec(
editorCommand: string,
pathValue: string,
platform: NodeJS.Platform
): ExternalEditorExecutableLaunchSpec {
const spec: ExternalEditorExecutableLaunchSpec = {
kind: 'executable',
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
spawnCmd: editorCommand,
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
if (spec.hideWindowsConsole && isJetBrainsConsoleShim(editorCommand, platform)) {
spec.detachedGui = true
}
return spec
}
function buildShellLaunchSpec(
command: string,
pathValue: string,
@@ -140,6 +165,11 @@ function buildShellLaunchSpec(
): ExternalEditorLaunchSpec {
const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}`
if (platform === 'win32') {
// Why: no `start` wrap here. `start` re-parses the line — it swallows the
// first quoted operand as a window title and mangles remote paths with
// spaces — and on a batch target it leaves a resident nested `cmd /K`.
// User-authored compound commands run verbatim; users who want a detached
// launch already write `start` themselves.
return {
kind: 'shell',
hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }),
@@ -165,26 +195,24 @@ export function resolveExternalEditorLaunchSpec(
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
const editorCommand = stripMatchingQuotes(trimmed)
return {
kind: 'executable',
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
spawnCmd: editorCommand,
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
// Why: settings often store a full path to idea.cmd / idea.exe; upgrade
// those the same way as PATH-resolved names when *64.exe is colocated.
return buildExecutableLaunchSpec(
preferJetBrainsGuiExecutable(stripMatchingQuotes(trimmed), platform, fileExists),
pathValue,
platform
)
}
if (isCompoundShellCommand(trimmed)) {
return buildShellLaunchSpec(trimmed, pathValue, platform)
}
const editorCommand = resolveCliCommand(trimmed, { platform })
return {
kind: 'executable',
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
spawnCmd: editorCommand,
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
}
return buildExecutableLaunchSpec(
resolveSimpleEditorCommand(trimmed, platform, fileExists),
pathValue,
platform
)
}
export function resolveVsCodeRemoteSshLaunchSpec(
@@ -217,3 +245,52 @@ export function resolveVsCodeRemoteSshLaunchSpec(
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
}
}
function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): {
spawnCmd: string
spawnArgs: string[]
windowsHide: boolean
} {
// Why: only shims flagged during resolution (JetBrains) take the start /B
// detach; every other launcher keeps the waiting form so its argv survives.
if (launchSpec.kind === 'executable') {
const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, {
detachedGui: launchSpec.detachedGui === true
})
return { ...spawned, windowsHide: launchSpec.hideWindowsConsole }
}
return {
spawnCmd: launchSpec.spawnCmd,
spawnArgs: launchSpec.spawnArgs,
windowsHide: launchSpec.hideWindowsConsole
}
}
export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec)
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', windowsHide })
let settled = false
function cleanup(): void {
child.off('error', onError)
child.off('spawn', onSpawn)
}
function settle(callback: () => void): void {
if (settled) {
return
}
settled = true
cleanup()
callback()
}
function onError(error: Error): void {
settle(() => rejectPromise(error))
}
function onSpawn(): void {
child.unref()
settle(resolvePromise)
}
child.once('error', onError)
child.once('spawn', onSpawn)
})
}
@@ -298,6 +298,49 @@ describe('renderer breadcrumb IPC routing', () => {
])
})
// Why: the renderer guard is once per tab-id/verdict, so one stale worktree
// map can emit enough crumbs to evict the pre-crash trail.
it('coalesces duplicate-tab-owner notices across tabs', () => {
emitRendererBreadcrumb({
name: 'terminal_tab_id_owned_by_multiple_worktrees',
data: { ownerCount: 2, resolvedToActiveWorktree: true }
})
emitRendererBreadcrumb({
name: 'terminal_tab_id_owned_by_multiple_worktrees',
data: { ownerCount: 3, resolvedToActiveWorktree: true }
})
expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled()
expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2)
for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) {
expect(call[0]).toMatchObject({
coalesceKey: 'terminal_tab_id_owned_by_multiple_worktrees:true'
})
}
})
// Why flag-scoped: coalescing keeps only the newest payload, and the verdict
// flips under a persisting duplicate, so one would erase the other.
it('keeps a non-converging duplicate-tab-owner notice out of the converging one', () => {
emitRendererBreadcrumb({
name: 'terminal_tab_id_owned_by_multiple_worktrees',
data: { ownerCount: 2, resolvedToActiveWorktree: false }
})
emitRendererBreadcrumb({
name: 'terminal_tab_id_owned_by_multiple_worktrees',
data: { ownerCount: 2, resolvedToActiveWorktree: true }
})
expect(
recordCoalescedCrashBreadcrumbMock.mock.calls.map(
(call) => (call[0] as { coalesceKey: string }).coalesceKey
)
).toEqual([
'terminal_tab_id_owned_by_multiple_worktrees:false',
'terminal_tab_id_owned_by_multiple_worktrees:true'
])
})
it('records non-error renderer breadcrumbs without coalescing', () => {
emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } })
+7
View File
@@ -326,11 +326,13 @@ function buildUncapturedCrashReportText(
// storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds,
// erasing the pre-crash trail. Coalesce repeats into one entry that carries a
// suppressed count instead.
const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees'
const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
'renderer_error',
'renderer_unhandled_rejection',
'terminal_park_verdict_churn',
'terminal_safe_fit_retry_exhausted',
DUPLICATE_TAB_OWNER_BREADCRUMB,
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
])
const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
@@ -362,6 +364,11 @@ function rendererBreadcrumbCoalesceKey(
if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) {
return `${name}:${String(data?.kind ?? '')}`
}
// Why: a stale map can emit once per tab-id/verdict; key by verdict so
// last-write coalescing cannot erase the other signal while remaining bounded.
if (name === DUPLICATE_TAB_OWNER_BREADCRUMB) {
return `${name}:${String(data?.resolvedToActiveWorktree ?? '')}`
}
const primaryMessage = name === 'renderer_error' ? data?.message : data?.reasonMessage
const fallbackMessage = name === 'renderer_error' ? data?.errorMessage : undefined
const message =
+116 -46
View File
@@ -297,9 +297,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: false
}
)
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -320,9 +324,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: false
}
)
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -334,16 +342,25 @@ describe('registerShellHandlers', () => {
})
it('uses a provided launcher command', async () => {
resolveCliCommandMock.mockReturnValueOnce('custom-editor')
const workspacePath = resolve('workspace')
const handler = getHandler('shell:openInExternalEditor')
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual(
{
ok: true
}
)
expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', {
platform: process.platform
})
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'custom-editor',
[normalize(workspacePath)],
{
detachedGui: false
}
)
})
it.runIf(process.platform === 'win32')(
@@ -357,11 +374,13 @@ describe('registerShellHandlers', () => {
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [
'--remote',
'wsl+Ubuntu Preview',
'/home/Ada Lovelace/project'
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
codeShim,
['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'],
{
detachedGui: false
}
)
}
)
@@ -377,9 +396,13 @@ describe('registerShellHandlers', () => {
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
nvimPath,
[normalize(workspacePath)],
{
detachedGui: false
}
)
expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -392,6 +415,37 @@ describe('registerShellHandlers', () => {
}
})
it('detaches JetBrains batch shims on Windows but leaves other launchers waiting', async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const workspacePath = normalize(resolve('workspace'))
const ideaShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
const codeShim = 'C:\\Tools\\code.cmd'
const handler = getHandler('shell:openInExternalEditor')
try {
resolveCliCommandMock.mockReturnValueOnce(ideaShim)
await expect(handler({}, { path: workspacePath, command: 'idea' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(ideaShim, [workspacePath], {
detachedGui: true
})
resolveCliCommandMock.mockReturnValueOnce(codeShim)
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(codeShim, [workspacePath], {
detachedGui: false
})
} finally {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
}
})
it('forces Cursor launcher folders into a new window', async () => {
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor')
const workspacePath = resolve('workspace')
@@ -400,18 +454,24 @@ describe('registerShellHandlers', () => {
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [
'--new-window',
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/cursor',
['--new-window', normalize(workspacePath)],
{
detachedGui: false
}
)
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [
'--new-window',
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(
'C:\\Cursor\\cursor.cmd',
['--new-window', normalize(workspacePath)],
{
detachedGui: false
}
)
})
it('falls back to VS Code when command is blank', async () => {
@@ -438,9 +498,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: false
}
)
expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], {
detached: true,
stdio: 'ignore',
@@ -510,11 +574,13 @@ describe('registerShellHandlers', () => {
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder',
remotePath
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/code',
['--remote', 'ssh-remote+builder', remotePath],
{
detachedGui: false
}
)
})
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
@@ -535,11 +601,13 @@ describe('registerShellHandlers', () => {
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [
'--remote',
'ssh-remote+Ada@win-builder.example.com',
remotePath
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'C:\\Tools\\code.cmd',
['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath],
{
detachedGui: false
}
)
})
it('opens a manual port-22 target with a host-only authority when username is blank', async () => {
@@ -558,11 +626,13 @@ describe('registerShellHandlers', () => {
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder.example.com',
'/srv/project'
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/code',
['--remote', 'ssh-remote+builder.example.com', '/srv/project'],
{
detachedGui: false
}
)
})
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
+2 -47
View File
@@ -1,5 +1,4 @@
import { ipcMain, shell, dialog } from 'electron'
import { spawn } from 'node:child_process'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -10,12 +9,11 @@ import type {
} from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import type { Store } from '../persistence'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
launchExternalEditor,
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec,
type ExternalEditorLaunchSpec
resolveVsCodeRemoteSshLaunchSpec
} from '../external-editor-launch'
import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority'
@@ -72,49 +70,6 @@ async function openInFileManager(
}
}
async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs } =
launchSpec.kind === 'executable'
? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs)
: { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs }
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(spawnCmd, spawnArgs, {
detached: true,
stdio: 'ignore',
// Why: terminal editors such as nvim need a visible console on Windows;
// GUI editor launches stay hidden to avoid command-shim flashes.
windowsHide: launchSpec.hideWindowsConsole
})
let settled = false
function cleanup(): void {
child.off('error', onError)
child.off('spawn', onSpawn)
}
function settle(callback: () => void): void {
if (settled) {
return
}
settled = true
cleanup()
callback()
}
function onError(error: Error): void {
settle(() => rejectPromise(error))
}
function onSpawn(): void {
child.unref()
settle(resolvePromise)
}
child.once('error', onError)
child.once('spawn', onSpawn)
})
}
async function openInExternalEditor(
store: Store,
request: ShellOpenExternalEditorRequest
@@ -0,0 +1,91 @@
import { win32 } from 'node:path'
import {
getLauncherBaseName,
isWindowsBatchLauncher,
stripMatchingQuotes
} from './editor-launcher-name'
// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The
// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe`
// stubs) chain through helpers that allocate a visible Command Prompt even
// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop.
// Maps each short console launcher name to its GUI sibling.
const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: Readonly<Record<string, string>> = {
idea: 'idea64',
webstorm: 'webstorm64',
pycharm: 'pycharm64',
phpstorm: 'phpstorm64',
goland: 'goland64',
rider: 'rider64',
clion: 'clion64',
rubymine: 'rubymine64',
datagrip: 'datagrip64',
rustrover: 'rustrover64',
studio: 'studio64'
}
const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI))
/** GUI binary basename for a console or GUI launcher name, or null if unknown. */
function getGuiExecutableName(launcherBaseName: string): string | null {
if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) {
return launcherBaseName
}
return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null
}
/**
* The GUI `*64.exe` beside an already-resolved console entry point, or null.
*
* Why sibling-only: a bare PATH lookup for `idea64` can land in a different,
* stale install, and it costs a second full PATH walk on Electron's main
* thread. Toolbox script directories ship no exe, so those keep the shim and
* rely on the detached-GUI spawn instead.
*
* Upgrades:
* - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present
* - short-name console stubs `idea.exe` → colocated `idea64.exe` when present
* - leaves `idea64.exe` and non-JetBrains launchers unchanged
*/
export function resolveColocatedJetBrainsGuiExecutable(
resolvedCommand: string,
fileExists: (path: string) => boolean
): string | null {
const unquoted = stripMatchingQuotes(resolvedCommand)
const baseName = getLauncherBaseName(unquoted)
const isBatch = /\.(?:cmd|bat)$/i.test(unquoted)
const isExe = /\.exe$/i.test(unquoted)
if (!isBatch && !isExe) {
return null
}
// .exe: only short console names (idea.exe), never the GUI binary itself.
if (isExe) {
const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName]
if (!guiName) {
return null
}
const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`)
return fileExists(candidate) ? candidate : null
}
// .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated.
const guiName = getGuiExecutableName(baseName)
if (!guiName) {
return null
}
const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`)
return fileExists(candidate) ? candidate : null
}
// Why: only JetBrains batch shims chain through console helpers that outlive
// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit
// on their own, and running them under `start` would re-parse their quoted
// WSL/SSH remote argv. detachedGui only rewrites batch targets.
export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean {
return (
platform === 'win32' &&
isWindowsBatchLauncher(command) &&
getGuiExecutableName(getLauncherBaseName(command)) !== null
)
}
+49
View File
@@ -82,6 +82,55 @@ describe('getSpawnArgsForWindows', () => {
}
})
it('routes GUI Open In .cmd launches through start /B with an inner cmd /c', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
'C:\\Tools\\idea.cmd',
['C:\\workspaces\\orca'],
{ detachedGui: true }
)
expect(spawnCmd).toBe(getCmdExePath())
// Why: `start` runs a batch target under a nested `cmd /K` that never
// exits; the inner `cmd /d /c` is what keeps the hidden shell from leaking.
// Title is empty string so libuv emits `""` — not the two-char `'""'`.
expect(spawnArgs).toEqual([
'/d',
'/c',
'start',
'',
'/B',
getCmdExePath(),
'/d',
'/c',
'C:\\Tools\\idea.cmd',
'C:\\workspaces\\orca'
])
expect(spawnArgs[3]).toBe('')
expect(spawnArgs).not.toContain('/K')
expect(spawnArgs).not.toContain('""')
expect(spawnArgs[spawnArgs.indexOf('/B') + 1]).not.toMatch(/\.(?:cmd|bat)$/i)
})
})
it('keeps the waiting form for batch launches without detachedGui', () => {
withPlatform('win32', () => {
const { spawnArgs } = getSpawnArgsForWindows('C:\\Tools\\idea.cmd', ['C:\\workspaces\\orca'])
expect(spawnArgs).toEqual(['/d', '/c', 'C:\\Tools\\idea.cmd', 'C:\\workspaces\\orca'])
})
})
it('leaves .exe GUI launches alone even when detachedGui is requested', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe',
['C:\\workspaces\\orca'],
{ detachedGui: true }
)
expect(spawnCmd).toBe('C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe')
expect(spawnArgs).toEqual(['C:\\workspaces\\orca'])
})
})
it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [
+2 -1
View File
@@ -8,7 +8,8 @@ export {
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
UnsafeWindowsBatchArgumentsError
UnsafeWindowsBatchArgumentsError,
type GetSpawnArgsForWindowsOptions
} from '../shared/windows-batch-spawn'
function execFileWithoutBlocking(
+8 -22
View File
@@ -64,7 +64,7 @@ import {
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair'
import { useActiveTerminalRepair } from './terminal/use-active-terminal-repair'
import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility'
import {
applyBackgroundMountTabRestriction,
@@ -390,7 +390,10 @@ function Terminal(): React.JSX.Element | null {
}, [foregroundTerminalTabIds])
const tabs = useMemo(
() => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []),
() =>
renderedActiveWorktreeId !== null && Object.hasOwn(tabsByWorktree, renderedActiveWorktreeId)
? tabsByWorktree[renderedActiveWorktreeId]
: [],
[renderedActiveWorktreeId, tabsByWorktree]
)
useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded)
@@ -749,32 +752,15 @@ function Terminal(): React.JSX.Element | null {
)
}, [queueEditorCloseRequests])
useEffect(() => {
const rememberedTabId = renderedActiveWorktreeId
? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null)
: null
// Why: prefer the remembered active tab so a repair on a transient switch render doesn't reset selection to Terminal 1.
const repairedTabId = resolveRepairedActiveTerminalTabId({
activeTabType,
activeTabId,
rememberedTabId,
tabs
})
if (!repairedTabId) {
return
}
// Why: run in an effect (Zustand mutation during render trips React's cross-component update warning); keep terminal-only so inactive CLI-created tabs can't steal editor/browser focus.
setActiveTab(repairedTabId)
// Why: `tabs` is the dependency so the repair reacts to tab-order/content changes, not just scalar IDs.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// Why: repair after render so Zustand mutation cannot trip React's cross-component update warning.
useActiveTerminalRepair({
activeTabId,
activeTabType,
setActiveTab,
tabs,
activeTabIdByWorktree,
renderedActiveWorktreeId
])
})
// Why: only mount TerminalPanes for visited worktrees, else restoring many saved tabs mass-spawns PTYs.
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
@@ -63,6 +63,44 @@ describe('createRemoteRuntimePtyTransport', () => {
return payload
}
function subscribedTerminalHandles(): string[] {
return subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.flatMap((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
return []
}
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
return payload ? [payload.terminal] : []
})
}
function readyHostSessionInventoryResponse(terminal: string, hostTabId = 'host-tab-1'): unknown {
return {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-ready',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: `${hostTabId}::pane:1`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `${hostTabId}::pane:1`,
parentTabId: hostTabId,
leafId: 'pane:1',
title: 'Terminal',
isActive: true,
status: 'ready',
terminal
}
]
}
}
}
function emitOutput(streamId: number, data: string): void {
subscriptionCallbacks?.onBinary?.(
encodeTerminalStreamFrame({
@@ -1954,6 +1992,770 @@ describe('createRemoteRuntimePtyTransport', () => {
})
})
it('retries inventory and reattaches the same HUB handle after a stream ends', async () => {
vi.useFakeTimers()
try {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const onPtyRebind = vi.fn()
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyExit,
onPtyRebind
})
resolvedPaneHandle = 'terminal-stable'
transport.attach({
existingPtyId: 'remote:hub-env@@terminal-stable',
cols: 100,
rows: 30,
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'before stream end')
expect(transport.isConnected()).toBe(true)
let inventoryAvailable = false
let hostListCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method !== 'session.tabs.list') {
return { ok: true, result: {} }
}
hostListCalls += 1
if (!inventoryAvailable) {
throw new Error('runtime reconnect in progress')
}
return readyHostSessionInventoryResponse('terminal-stable')
})
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
const subscribeCount = (): number =>
subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe).length
expect(subscribeCount()).toBe(1)
expect(hostListCalls).toBeGreaterThan(1)
expect(hostListCalls).toBeLessThan(25)
inventoryAvailable = true
await vi.advanceTimersByTimeAsync(16_000)
expect(hostListCalls).toBeLessThan(40)
expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable'])
expect(onPtyRebind).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:hub-env@@terminal-stable')
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'same handle reattached')
expect(transport.isConnected()).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('caps unavailable host inventory at two recovery windows', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-stable'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:hub-env@@terminal-stable',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'before inventory outage')
let hostListCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'session.tabs.list') {
hostListCalls += 1
throw new Error('runtime reconnect in progress')
}
return { ok: true, result: {} }
})
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(32_000)
const callsAfterTwoWindows = hostListCalls
expect(callsAfterTwoWindows).toBeGreaterThan(25)
expect(callsAfterTwoWindows).toBeLessThan(40)
await vi.advanceTimersByTimeAsync(20_000)
expect(hostListCalls).toBe(callsAfterTwoWindows)
expect(transport.getRecoveryState?.().phase).toBe('recovering')
await vi.advanceTimersByTimeAsync(9_000)
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
expect(subscribedTerminalHandles()).toEqual(['terminal-stable'])
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('reattaches from prior ready evidence when the trailing inventory poll fails', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-stable'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:hub-env@@terminal-stable',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'before trailing inventory failure')
let hostListCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method !== 'session.tabs.list') {
return { ok: true, result: {} }
}
hostListCalls += 1
if (hostListCalls === 1) {
return readyHostSessionInventoryResponse('terminal-stable')
}
throw new Error('final inventory poll failed')
})
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(hostListCalls).toBeGreaterThan(1)
expect(hostListCalls).toBeLessThan(25)
expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable'])
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'reattached from ready evidence')
expect(transport.isConnected()).toBe(true)
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('strengthens repeated same-handle end recovery until it disconnects', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-flapping'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:hub-env@@terminal-flapping',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'session.tabs.list'
? readyHostSessionInventoryResponse('terminal-flapping')
: { ok: true, result: {} }
)
emitSnapshot(latestSubscribePayload().streamId, 'initial stream')
for (let cycle = 0; cycle < 2; cycle += 1) {
const endingStreamId = latestSubscribePayload().streamId
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: endingStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(subscribedTerminalHandles()).toHaveLength(cycle + 2)
emitSnapshot(latestSubscribePayload().streamId, `same handle cycle ${cycle}`)
expect(transport.isConnected()).toBe(true)
}
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: latestSubscribePayload().streamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(subscribedTerminalHandles()).toEqual([
'terminal-flapping',
'terminal-flapping',
'terminal-flapping'
])
expect(transport.isConnected()).toBe(false)
await vi.advanceTimersByTimeAsync(45_000)
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('ignores a delayed stale send after the pane has rebound to a healthy handle', async () => {
resolvedPaneHandle = 'terminal-old'
let resolveOldSend: (response: unknown) => void = () => {}
const oldSendResponse = new Promise((resolve) => {
resolveOldSend = resolve
})
let hostListCalls = 0
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-old',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'old handle')
runtimeCall.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.send') {
return oldSendResponse
}
if (args.method === 'session.tabs.list') {
hostListCalls += 1
return Promise.resolve(readyHostSessionInventoryResponse('terminal-new'))
}
return Promise.resolve({ ok: true, result: {} })
})
const sendInputAccepted = transport.sendInputAccepted
if (!sendInputAccepted) {
throw new Error('Expected acknowledged remote terminal input')
}
const pendingSend = sendInputAccepted('sent-before-rebind')
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' }))
)
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'error', streamId: oldStreamId, message: 'terminal_handle_stale' }
})
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-new' })
)
emitSnapshot(latestSubscribePayload().streamId, 'new handle')
expect(transport.isConnected()).toBe(true)
resolveOldSend({
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
})
await expect(pendingSend).resolves.toBe(false)
expect(subscribedTerminalHandles()).toEqual(['terminal-old', 'terminal-new'])
expect(hostListCalls).toBe(1)
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-new')
expect(transport.isConnected()).toBe(true)
})
it('honors a sticky replacement requirement during non-web pane resolution', async () => {
resolvedPaneHandle = 'terminal-old'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-old',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
emitSnapshot(latestSubscribePayload().streamId, 'old handle')
let resolveOldSend: (response: unknown) => void = () => {}
const oldSendResponse = new Promise((resolve) => {
resolveOldSend = resolve
})
let resolvePane: (response: unknown) => void = () => {}
const paneResponse = new Promise((resolve) => {
resolvePane = resolve
})
runtimeCall.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.send') {
return oldSendResponse
}
if (args.method === 'terminal.resolvePane') {
return paneResponse
}
return Promise.resolve({ ok: true, result: {} })
})
const sendInputAccepted = transport.sendInputAccepted
if (!sendInputAccepted) {
throw new Error('Expected acknowledged remote terminal input')
}
const pendingSend = sendInputAccepted('sent-before-close')
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.send' }))
)
subscriptionCallbacks?.onClose?.()
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.resolvePane' })
)
)
resolveOldSend({
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
})
await expect(pendingSend).resolves.toBe(false)
resolvePane({
ok: true,
result: {
terminal: {
handle: 'terminal-old',
tabId: 'tab-1',
leafId: 'pane:1',
worktreeId: 'wt-1'
}
}
})
await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledOnce())
expect(subscribedTerminalHandles()).toEqual(['terminal-old'])
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-old')
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
})
it('does not subscribe a same handle condemned after its inventory wait returns', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-old'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyRebind = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyRebind
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-old',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'old handle')
let resolveOldSend: (response: unknown) => void = () => {}
const oldSendResponse = new Promise((resolve) => {
resolveOldSend = resolve
})
let hostListCalls = 0
runtimeCall.mockImplementation(
(args: { method: string; timeoutMs?: number }): Promise<unknown> => {
if (args.method === 'terminal.send') {
return oldSendResponse
}
if (args.method !== 'session.tabs.list') {
return Promise.resolve({ ok: true, result: {} })
}
hostListCalls += 1
const response = readyHostSessionInventoryResponse('terminal-old')
if ((args.timeoutMs ?? 15_000) > 1_000) {
return Promise.resolve(response)
}
return new Promise((resolve) => {
setTimeout(() => {
// Why: settle the stale send between the inner wait and its caller's continuation.
queueMicrotask(() =>
queueMicrotask(() =>
resolveOldSend({
ok: false,
error: {
code: 'terminal_handle_stale',
message: 'terminal_handle_stale'
}
})
)
)
resolve(response)
}, args.timeoutMs)
})
}
)
const sendInputAccepted = transport.sendInputAccepted
if (!sendInputAccepted) {
throw new Error('Expected acknowledged remote terminal input')
}
const pendingSend = sendInputAccepted('sent-before-stream-end')
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.send' })
)
)
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
await expect(pendingSend).resolves.toBe(false)
expect(hostListCalls).toBeGreaterThan(1)
expect(subscribedTerminalHandles()).toEqual(['terminal-old'])
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old')
expect(transport.isConnected()).toBe(false)
const handleEvents = await import('../../runtime/web-session-terminal-handle-events')
handleEvents.queueAcceptedWebSessionTerminalSnapshot(
{
worktree: 'wt-1',
publicationEpoch: 'epoch-replacement',
snapshotVersion: 3,
activeGroupId: null,
activeTabId: 'host-tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'host-tab-1::pane:1',
parentTabId: 'host-tab-1',
leafId: 'pane:1',
title: 'Terminal',
isActive: true,
status: 'ready',
terminal: 'terminal-new'
}
]
},
'env-1'
)
await vi.advanceTimersByTimeAsync(0)
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-new' })
)
expect(onPtyRebind).toHaveBeenCalledWith(
'remote:env-1@@terminal-new',
'remote:env-1@@terminal-old'
)
} finally {
vi.useRealTimers()
}
})
it('uses a strengthened sticky policy when an inventory retry starts', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-old'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-old',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'old handle')
let resolveOldSend: (response: unknown) => void = () => {}
const oldSendResponse = new Promise((resolve) => {
resolveOldSend = resolve
})
let hostListCalls = 0
runtimeCall.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.send') {
return oldSendResponse
}
if (args.method === 'session.tabs.list') {
hostListCalls += 1
return Promise.reject(new Error('runtime reconnect in progress'))
}
return Promise.resolve({ ok: true, result: {} })
})
const sendInputAccepted = transport.sendInputAccepted
if (!sendInputAccepted) {
throw new Error('Expected acknowledged remote terminal input')
}
const pendingSend = sendInputAccepted('sent-before-stream-end')
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.send' })
)
)
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(15_000)
expect(transport.getRecoveryState?.().phase).toBe('backoff')
resolveOldSend({
ok: false,
error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
})
await expect(pendingSend).resolves.toBe(false)
const callsBeforeRetry = hostListCalls
await vi.advanceTimersByTimeAsync(1_000)
expect(hostListCalls).toBe(callsBeforeRetry)
expect(subscribedTerminalHandles()).toEqual(['terminal-old'])
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-old')
expect(transport.isConnected()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('requires replacement after repeated same-handle stream-end flaps', async () => {
vi.useFakeTimers()
try {
resolvedPaneHandle = 'terminal-flapping'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyExit
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-flapping',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
emitSnapshot(latestSubscribePayload().streamId, 'initial handle')
runtimeCall.mockImplementation((args: { method: string }) =>
args.method === 'session.tabs.list'
? Promise.resolve(readyHostSessionInventoryResponse('terminal-flapping'))
: Promise.resolve({ ok: true, result: {} })
)
for (let cycle = 0; cycle < 2; cycle += 1) {
const endedStreamId = latestSubscribePayload().streamId
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: endedStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(subscribedTerminalHandles()).toHaveLength(cycle + 2)
expect(latestSubscribePayload().terminal).toBe('terminal-flapping')
emitSnapshot(latestSubscribePayload().streamId, `same handle ${cycle + 1}`)
expect(transport.isConnected()).toBe(true)
}
const condemnedStreamId = latestSubscribePayload().streamId
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: condemnedStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(subscribedTerminalHandles()).toEqual([
'terminal-flapping',
'terminal-flapping',
'terminal-flapping'
])
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-flapping')
expect(transport.isConnected()).toBe(false)
expect(onPtyExit).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(44_001)
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
expect(subscribedTerminalHandles()).toHaveLength(3)
} finally {
vi.useRealTimers()
}
})
it('reuses prior ready evidence when the trailing inventory poll fails', async () => {
vi.useFakeTimers()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
resolvedPaneHandle = 'terminal-stable'
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-stable',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const oldStreamId = latestSubscribePayload().streamId
emitSnapshot(oldStreamId, 'before trailing failure')
let hostListCalls = 0
runtimeCall.mockImplementation(
async (args: { method: string; timeoutMs?: number }): Promise<unknown> => {
if (args.method !== 'session.tabs.list') {
return { ok: true, result: {} }
}
hostListCalls += 1
if ((args.timeoutMs ?? 15_000) <= 1_000) {
throw new Error('final inventory poll failed')
}
return readyHostSessionInventoryResponse('terminal-stable')
}
)
subscriptionCallbacks?.onResponse({
ok: true,
result: { type: 'end', streamId: oldStreamId, code: 0 }
})
await vi.advanceTimersByTimeAsync(16_000)
expect(hostListCalls).toBeGreaterThan(1)
expect(subscribedTerminalHandles()).toEqual(['terminal-stable', 'terminal-stable'])
expect(warn).not.toHaveBeenCalled()
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'ready evidence reused')
expect(transport.isConnected()).toBe(true)
} finally {
warn.mockRestore()
vi.useRealTimers()
}
})
it('does not carry a stale-handle requirement onto the replacement stream', async () => {
vi.useFakeTimers()
try {
let rejectedReplacement = false
subscriptionSendBinary.mockImplementation((bytes: Uint8Array<ArrayBufferLike>) => {
const frame = decodeTerminalStreamFrame(bytes)
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
return
}
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
if (payload?.terminal === 'terminal-replacement' && !rejectedReplacement) {
rejectedReplacement = true
throw new Error('Remote runtime connection closed.')
}
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyRebind = vi.fn()
const transport = createRemoteRuntimePtyTransport('hub-env', {
worktreeId: 'wt-1',
tabId: 'web-terminal-host-tab-1',
leafId: 'pane:1',
onPtyRebind
})
resolvedPaneHandle = 'terminal-stale'
transport.attach({
existingPtyId: 'remote:hub-env@@terminal-stale',
callbacks: {}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
const staleStreamId = latestSubscribePayload().streamId
emitSnapshot(staleStreamId, 'before stale handle')
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method !== 'session.tabs.list') {
return { ok: true, result: {} }
}
return {
ok: true,
result: {
worktree: 'wt-1',
publicationEpoch: 'epoch-replacement',
snapshotVersion: 2,
activeGroupId: null,
activeTabId: 'host-tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'host-tab-1::pane:1',
parentTabId: 'host-tab-1',
leafId: 'pane:1',
title: 'Terminal',
isActive: true,
status: 'ready',
terminal: 'terminal-replacement'
}
]
}
}
})
subscriptionCallbacks?.onResponse({
ok: true,
result: {
type: 'error',
streamId: staleStreamId,
message: 'terminal_handle_stale'
}
})
await vi.waitFor(() => expect(rejectedReplacement).toBe(true))
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(runtimeSubscribe.mock.calls.length).toBeGreaterThanOrEqual(2))
const subscribedTerminals = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.flatMap((frame) => {
if (frame?.opcode !== TerminalStreamOpcode.Subscribe) {
return []
}
const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload)
return payload ? [payload.terminal] : []
})
expect(subscribedTerminals).toEqual([
'terminal-stale',
'terminal-replacement',
'terminal-replacement'
])
expect(onPtyRebind).toHaveBeenCalledOnce()
await vi.waitFor(() =>
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-replacement' })
)
emitSnapshot(latestSubscribePayload().streamId, 'replacement reattached')
expect(transport.isConnected()).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('coalesces concurrent stale errors for the handle that was replaced', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtyExit = vi.fn()
@@ -86,8 +86,29 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
const HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY = 2
const HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT = 2
const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const
type HostHandleReplacementPolicy = 'reuse' | 'prefer-replacement' | 'require-replacement'
type HostSessionHandleWaitResult = {
handle: string | null | undefined
inventoryFailed: boolean
}
function stricterReplacementPolicy(
left: HostHandleReplacementPolicy,
right: HostHandleReplacementPolicy
): HostHandleReplacementPolicy {
const rank: Record<HostHandleReplacementPolicy, number> = {
reuse: 0,
'prefer-replacement': 1,
'require-replacement': 2
}
return rank[left] >= rank[right] ? left : right
}
type RemoteAgentSessionLaunchResult =
| RuntimeEnsureAgentSessionResult
| RuntimeCreateAgentSessionResult
@@ -162,10 +183,16 @@ export function createRemoteRuntimePtyTransport(
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
let resubscribeEpoch: number | null = null
let resubscribeRequestedHandle: string | null = null
let resubscribeRequestedRequiresReplacement = false
let recoveryRequiresReplacement = false
let resubscribeRequestedReplacementPolicy: HostHandleReplacementPolicy = 'reuse'
let recoveryReplacementPolicy: HostHandleReplacementPolicy = 'reuse'
let recoveryReplacementPolicyHandle: string | null = null
let stopWaitingForPublishedHandle: (() => void) | null = null
let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null
let resubscribeInventoryEpoch: number | null = null
let resubscribeInventoryWindows = 0
let sameHandleEndReuseHandle: string | null = null
let sameHandleEndReuseCount = 0
let sameHandleEndReuseAttachedAt: number | null = null
let attachGeneration = 0
let subscriptionGeneration = 0
@@ -240,6 +267,62 @@ export function createRemoteRuntimePtyTransport(
let lastAttachOptions: Parameters<PtyTransport['attach']>[0] | null = null
let resolvePaneUnavailable = false
let recoveringPaneHandle: string | null = null
const getRecoveryReplacementPolicy = (targetHandle: string): HostHandleReplacementPolicy =>
recoveryReplacementPolicyHandle === targetHandle ? recoveryReplacementPolicy : 'reuse'
const resetRecoveryReplacementPolicy = (): void => {
recoveryReplacementPolicy = 'reuse'
recoveryReplacementPolicyHandle = null
}
const strengthenRecoveryReplacementPolicy = (
targetHandle: string,
replacementPolicy: HostHandleReplacementPolicy
): void => {
if (recoveryReplacementPolicyHandle !== targetHandle) {
recoveryReplacementPolicyHandle = targetHandle
recoveryReplacementPolicy = replacementPolicy
return
}
recoveryReplacementPolicy = stricterReplacementPolicy(
recoveryReplacementPolicy,
replacementPolicy
)
}
const beginResubscribeInventoryWindow = (recoveryEpoch: number): number => {
if (resubscribeInventoryEpoch !== recoveryEpoch) {
resubscribeInventoryEpoch = recoveryEpoch
resubscribeInventoryWindows = 0
}
resubscribeInventoryWindows += 1
return resubscribeInventoryWindows
}
const resetSameHandleEndReuse = (): void => {
sameHandleEndReuseHandle = null
sameHandleEndReuseCount = 0
sameHandleEndReuseAttachedAt = null
}
const recordSameHandleEndReuse = (targetHandle: string): void => {
if (sameHandleEndReuseHandle !== targetHandle) {
sameHandleEndReuseHandle = targetHandle
sameHandleEndReuseCount = 0
}
sameHandleEndReuseCount += 1
sameHandleEndReuseAttachedAt = Date.now()
}
const replacementPolicyAfterWebStreamEnd = (
targetHandle: string
): HostHandleReplacementPolicy => {
if (
sameHandleEndReuseHandle !== targetHandle ||
sameHandleEndReuseAttachedAt === null ||
Date.now() - sameHandleEndReuseAttachedAt >= REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS
) {
resetSameHandleEndReuse()
return 'prefer-replacement'
}
return sameHandleEndReuseCount >= HOST_SESSION_SAME_HANDLE_END_REUSE_LIMIT
? 'require-replacement'
: 'prefer-replacement'
}
const adoptExecutionMetadata = (terminal: {
executionHostId?: ExecutionHostId
hostPlatform?: NodeJS.Platform
@@ -528,29 +611,46 @@ export function createRemoteRuntimePtyTransport(
async function waitForResubscribeHostSessionHandle(
hostTabId: string,
previousHandle: string,
requireReplacement: boolean
): Promise<string | null | undefined> {
replacementPolicy: HostHandleReplacementPolicy,
recoveryEpoch: number
): Promise<HostSessionHandleWaitResult> {
if (!worktreeId) {
return null
return { handle: null, inventoryFailed: false }
}
const worktree = toRuntimeWorktreeSelector(worktreeId)
const startedAt = Date.now()
let pollMs = HOST_SESSION_ATTACH_POLL_MS
let lastListError: unknown = null
const finishWithUnknownLiveness = (): undefined => {
let lastReadyHandle: string | null = null
let sawSuccessfulInventory = false
const finishBoundedWait = (): HostSessionHandleWaitResult => {
const effectivePolicy = stricterReplacementPolicy(
replacementPolicy,
getRecoveryReplacementPolicy(previousHandle)
)
if (effectivePolicy === 'prefer-replacement' && lastReadyHandle) {
return { handle: lastReadyHandle, inventoryFailed: false }
}
if (lastListError) {
console.warn(
'[remote-runtime-pty] host session inventory unavailable during reconnect:',
sawSuccessfulInventory
? '[remote-runtime-pty] final host session inventory poll failed during reconnect:'
: '[remote-runtime-pty] host session inventory unavailable during reconnect:',
runtimeTerminalErrorMessage(lastListError)
)
}
// Why: a bounded wait without removal evidence is unknown liveness; keep the pane for a later snapshot to reattach.
return undefined
return { handle: undefined, inventoryFailed: lastListError !== null }
}
while (!destroyed && connected && handle === previousHandle) {
while (
!destroyed &&
connected &&
handle === previousHandle &&
recovery.isCurrent(recoveryEpoch)
) {
const requestRemainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
if (requestRemainingMs <= 0) {
return finishWithUnknownLiveness()
return finishBoundedWait()
}
try {
const listed = await listRemoteRuntimeSessionTabsDeduped({
@@ -566,12 +666,20 @@ export function createRemoteRuntimePtyTransport(
)
})
lastListError = null
sawSuccessfulInventory = true
const nextHandle = findReadyHostSessionHandle(listed, hostTabId)
if (nextHandle && (!requireReplacement || nextHandle !== previousHandle)) {
return nextHandle
if (nextHandle) {
lastReadyHandle = nextHandle
}
const effectivePolicy = stricterReplacementPolicy(
replacementPolicy,
getRecoveryReplacementPolicy(previousHandle)
)
if (nextHandle && (effectivePolicy === 'reuse' || nextHandle !== previousHandle)) {
return { handle: nextHandle, inventoryFailed: false }
}
if (!hasHostSessionTerminalSurface(listed, hostTabId)) {
return null
return { handle: null, inventoryFailed: false }
}
} catch (error) {
// Why: the inventory can race the reconnect that invalidated the handle; unknown liveness must not retire the pane.
@@ -579,13 +687,13 @@ export function createRemoteRuntimePtyTransport(
}
const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt)
if (remainingMs <= 0) {
return finishWithUnknownLiveness()
return finishBoundedWait()
}
// Why: a stale response can precede its replacement; bounded backoff avoids retrying the stale handle in a hot loop.
await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remainingMs)))
pollMs = Math.min(pollMs * 2, HOST_SESSION_REPLACEMENT_POLL_MAX_MS)
}
return undefined
return { handle: undefined, inventoryFailed: false }
}
async function attachHostSessionMirror(
@@ -1068,7 +1176,9 @@ export function createRemoteRuntimePtyTransport(
return true
} catch (error) {
// Why: stale-handle errors must retire the mirror (recoverable via next snapshot), not dead-end in a red xterm banner (#7718).
handleRemoteTerminalError(error)
if (handle === targetHandle) {
handleRemoteTerminalError(error)
}
return false
}
}
@@ -1093,7 +1203,9 @@ export function createRemoteRuntimePtyTransport(
client: { id: clientId, type: 'desktop' },
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
}).catch((error) => {
handleRemoteTerminalError(error)
if (handle === targetHandle) {
handleRemoteTerminalError(error)
}
})
})
@@ -1159,7 +1271,8 @@ export function createRemoteRuntimePtyTransport(
function retireRemoteTerminalId(): void {
recovery.cancel()
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
connected = false
connecting = false
terminalEnded = true
@@ -1183,6 +1296,8 @@ export function createRemoteRuntimePtyTransport(
unregisterShutdownHandlers(replacedPtyId)
handle = nextHandle
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
registerShutdownHandlers(remotePtyId)
setAttachmentReady(false)
// Why: host handle rotation preserves the pane generation; only the store identity changes, not spawn/exit semantics.
@@ -1239,7 +1354,7 @@ export function createRemoteRuntimePtyTransport(
if (tabId && leafId && worktreeId) {
// Why: reconnect can re-mint a pane handle while its host coordinates live; keep xterm state mounted while re-resolving.
closeMultiplexedStream()
scheduleResubscribeAfterTransportClose(true)
scheduleResubscribeAfterTransportClose('require-replacement')
} else {
retireRemoteTerminalId()
}
@@ -1290,16 +1405,19 @@ export function createRemoteRuntimePtyTransport(
// Why: after a transport drop the host may have re-minted this handle; re-derive from the snapshot so we don't mirror/type into whatever PTY now sits behind the stale one (#7718).
async function resubscribeAfterTransportClose(
previousHandle: string,
requireReplacement: boolean,
replacementPolicy: HostHandleReplacementPolicy,
recoveryEpoch: number
): Promise<void> {
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
const hostTabId = toHostSessionTabId(tabId)
const nextHandle = await waitForResubscribeHostSessionHandle(
const inventoryWindow = beginResubscribeInventoryWindow(recoveryEpoch)
const waitResult = await waitForResubscribeHostSessionHandle(
hostTabId,
previousHandle,
requireReplacement
replacementPolicy,
recoveryEpoch
)
const nextHandle = waitResult.handle
if (
destroyed ||
!connected ||
@@ -1309,6 +1427,19 @@ export function createRemoteRuntimePtyTransport(
return
}
if (nextHandle === undefined) {
const effectivePolicy = stricterReplacementPolicy(
replacementPolicy,
getRecoveryReplacementPolicy(previousHandle)
)
if (
effectivePolicy !== 'require-replacement' &&
waitResult.inventoryFailed &&
inventoryWindow < HOST_SESSION_INVENTORY_MAX_WINDOWS_PER_RECOVERY
) {
throw Object.assign(new Error('Remote runtime session inventory polling failed.'), {
code: 'remote_runtime_unavailable'
})
}
return
}
if (!nextHandle) {
@@ -1316,15 +1447,36 @@ export function createRemoteRuntimePtyTransport(
retireRemoteTerminalId()
return
}
const effectivePolicy = stricterReplacementPolicy(
replacementPolicy,
getRecoveryReplacementPolicy(previousHandle)
)
// Why: a stale error can strengthen policy after inventory returns but before this continuation runs.
if (effectivePolicy === 'require-replacement' && nextHandle === previousHandle) {
return
}
if (nextHandle !== previousHandle) {
rebindRemoteTerminalHandle(nextHandle)
}
clearPublishedHandleWait()
await subscribeToHandle(
recoveryEpoch,
nextHandle === previousHandle && effectivePolicy === 'prefer-replacement'
)
return
} else if (tabId && leafId && worktreeId) {
const resolved = await resolvePersistedHostPane()
if (destroyed || !connected || handle !== previousHandle) {
return
}
if (!resolved || (requireReplacement && resolved.handle === previousHandle)) {
const effectivePolicy = stricterReplacementPolicy(
replacementPolicy,
getRecoveryReplacementPolicy(previousHandle)
)
if (
!resolved ||
(effectivePolicy === 'require-replacement' && resolved.handle === previousHandle)
) {
retireRemoteTerminalId()
return
}
@@ -1337,7 +1489,7 @@ export function createRemoteRuntimePtyTransport(
}
function scheduleResubscribeAfterTransportClose(
requireReplacement = false,
replacementPolicy: HostHandleReplacementPolicy = 'reuse',
requestedRecoveryEpoch?: number
): void {
if (destroyed || !connected || !handle) {
@@ -1354,8 +1506,8 @@ export function createRemoteRuntimePtyTransport(
viewportBatcher.clear()
clearPendingViewportClaim()
}
recoveryRequiresReplacement ||= requireReplacement
if (requireReplacement && stopWaitingForPublishedHandle) {
strengthenRecoveryReplacementPolicy(handle, replacementPolicy)
if (replacementPolicy === 'require-replacement' && stopWaitingForPublishedHandle) {
// Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs.
return
}
@@ -1363,9 +1515,12 @@ export function createRemoteRuntimePtyTransport(
// Why: concurrent stale errors belong to their own handle; don't carry an old handle's replacement requirement onto its successor.
if (resubscribeRequestedHandle !== handle) {
resubscribeRequestedHandle = handle
resubscribeRequestedRequiresReplacement = requireReplacement
resubscribeRequestedReplacementPolicy = replacementPolicy
} else {
resubscribeRequestedRequiresReplacement ||= requireReplacement
resubscribeRequestedReplacementPolicy = stricterReplacementPolicy(
resubscribeRequestedReplacementPolicy,
replacementPolicy
)
}
return
}
@@ -1377,16 +1532,19 @@ export function createRemoteRuntimePtyTransport(
}
resubscribeEpoch = recoveryEpoch
resubscribeRequestedHandle = null
resubscribeRequestedRequiresReplacement = false
resubscribeRequestedReplacementPolicy = 'reuse'
let retryScheduled = false
void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement, recoveryEpoch)
void resubscribeAfterTransportClose(resubscribeHandle, replacementPolicy, recoveryEpoch)
.catch((error) => {
if (!destroyed && connected && handle && recovery.isCurrent(recoveryEpoch)) {
clearPendingViewportClaim()
const clientError = toRemoteRuntimeClientErrorLike(error)
if (isRecoverableRemoteRuntimeConnectionError(clientError)) {
retryScheduled = recovery.schedule(recoveryEpoch, (nextEpoch) => {
scheduleResubscribeAfterTransportClose(requireReplacement, nextEpoch)
const currentReplacementPolicy = handle
? getRecoveryReplacementPolicy(handle)
: 'reuse'
scheduleResubscribeAfterTransportClose(currentReplacementPolicy, nextEpoch)
})
} else {
recovery.cancel()
@@ -1400,9 +1558,9 @@ export function createRemoteRuntimePtyTransport(
}
resubscribeEpoch = null
const pendingHandle = resubscribeRequestedHandle
const pendingRequiresReplacement = resubscribeRequestedRequiresReplacement
const pendingReplacementPolicy = resubscribeRequestedReplacementPolicy
resubscribeRequestedHandle = null
resubscribeRequestedRequiresReplacement = false
resubscribeRequestedReplacementPolicy = 'reuse'
if (
!retryScheduled &&
recovery.isCurrent(recoveryEpoch) &&
@@ -1411,7 +1569,7 @@ export function createRemoteRuntimePtyTransport(
pendingHandle === handle &&
!getCurrentMultiplexedStream(pendingHandle)
) {
scheduleResubscribeAfterTransportClose(pendingRequiresReplacement)
scheduleResubscribeAfterTransportClose(pendingReplacementPolicy)
}
})
}
@@ -1428,11 +1586,14 @@ export function createRemoteRuntimePtyTransport(
clearPendingViewportClaim()
}
recovery.schedule(recoveryEpoch, (nextEpoch) => {
scheduleResubscribeAfterTransportClose(false, nextEpoch)
scheduleResubscribeAfterTransportClose('reuse', nextEpoch)
})
}
async function subscribeToHandle(expectedRecoveryEpoch?: number): Promise<void> {
async function subscribeToHandle(
expectedRecoveryEpoch?: number,
sameHandleEndRecovery = false
): Promise<void> {
if (!handle) {
return
}
@@ -1495,10 +1656,13 @@ export function createRemoteRuntimePtyTransport(
desiredOutputPaused,
nextStream.setOutputPaused(desiredOutputPaused)
)
if (!subscriptionAttached && sameHandleEndRecovery) {
recordSameHandleEndReuse(subscribedHandle)
}
subscriptionAttached = true
setAttachmentReady(true)
connecting = false
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
recovery.markHealthy()
emitRecoveryState()
storedCallbacks.onConnect?.()
@@ -1514,8 +1678,10 @@ export function createRemoteRuntimePtyTransport(
multiplexedStream = null
multiplexedStreamHandle = null
clearPendingViewportClaim()
// Why: a HUB restart ends the old handle's stream before its replacement snapshot arrives; the HUB snapshot, not the paired viewer, decides whether the pane exited.
scheduleResubscribeAfterTransportClose(true)
// Why: repeated same-handle end/reuse cycles must eventually stop on a replacement boundary.
scheduleResubscribeAfterTransportClose(
replacementPolicyAfterWebStreamEnd(subscribedHandle)
)
return
}
unregisterShutdownHandlers(subscribedPtyId)
@@ -1564,6 +1730,7 @@ export function createRemoteRuntimePtyTransport(
multiplexedStream = null
multiplexedStreamHandle = null
setAttachmentReady(false)
resetSameHandleEndReuse()
if (recoverable) {
if (retryWithBackoff) {
scheduleCapacityPressureRetry()
@@ -1596,7 +1763,7 @@ export function createRemoteRuntimePtyTransport(
multiplexedStreamHandle = subscribedHandle
setAttachmentReady(subscriptionAttached)
if (subscriptionAttached) {
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
recovery.markHealthy()
}
// Why: a viewport change during the subscribe round-trip hit the no-op one-shot fallback; replay the latest viewport so the PTY isn't stuck at subscribe-time size.
@@ -1629,7 +1796,8 @@ export function createRemoteRuntimePtyTransport(
lastConnectOptions = options
lastAttachOptions = null
storedCallbacks = options.callbacks
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
terminalEnded = false
connecting = true
emitRecoveryState(true)
@@ -1846,7 +2014,8 @@ export function createRemoteRuntimePtyTransport(
const generation = ++attachGeneration
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
clearPublishedHandleWait()
lastAttachOptions = options
storedCallbacks = options.callbacks
@@ -1944,7 +2113,8 @@ export function createRemoteRuntimePtyTransport(
attachGeneration += 1
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
@@ -1975,7 +2145,8 @@ export function createRemoteRuntimePtyTransport(
attachGeneration += 1
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
resetRecoveryReplacementPolicy()
resetSameHandleEndReuse()
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
@@ -2033,7 +2204,9 @@ export function createRemoteRuntimePtyTransport(
client: { id: clientId, type: 'desktop' },
...(desiredViewport ? { viewport: desiredViewport, claimViewport: true as const } : {})
}).catch((error) => {
handleRemoteTerminalError(error)
if (handle === targetHandle) {
handleRemoteTerminalError(error)
}
})
return true
},
@@ -2134,7 +2307,7 @@ export function createRemoteRuntimePtyTransport(
return false
}
const recoveryEpoch = recovery.begin()
scheduleResubscribeAfterTransportClose(recoveryRequiresReplacement, recoveryEpoch)
scheduleResubscribeAfterTransportClose(getRecoveryReplacementPolicy(handle), recoveryEpoch)
return true
},
@@ -0,0 +1,327 @@
/** @vitest-environment happy-dom */
import { act, useMemo } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import { useActiveTerminalRepair } from './use-active-terminal-repair'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
// Why: React throws #185 at 51 nested commits; 400 proves divergence, not slowness.
const MAX_PASSES = 400
function terminalTab(id: string, worktreeId: string): TerminalTab {
return { id, worktreeId, title: id, createdAt: 0, sortOrder: 0 } as unknown as TerminalTab
}
function unifiedTerminalTab(
id: string,
entityId: string,
worktreeId: string,
groupId: string
): Tab {
return {
id,
entityId,
worktreeId,
groupId,
contentType: 'terminal',
label: id,
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function tabGroup(
id: string,
worktreeId: string,
activeTabId: string,
tabOrder: string[]
): TabGroup {
return { id, worktreeId, activeTabId, tabOrder, recentTabIds: [activeTabId] }
}
function RepairEffectHarness(): null {
const activeTabId = useAppStore((s) => s.activeTabId)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const activeTabType = useAppStore((s) => s.activeTabType)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const renderedActiveWorktreeId = useAppStore((s) => s.activeWorktreeId)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const tabs = useMemo(
() =>
renderedActiveWorktreeId !== null && Object.hasOwn(tabsByWorktree, renderedActiveWorktreeId)
? tabsByWorktree[renderedActiveWorktreeId]
: [],
[renderedActiveWorktreeId, tabsByWorktree]
)
useActiveTerminalRepair({
activeTabId,
activeTabType,
setActiveTab,
tabs,
activeTabIdByWorktree,
renderedActiveWorktreeId
})
return null
}
let cleanup: (() => void) | null = null
afterEach(() => {
cleanup?.()
cleanup = null
})
function measureRepairPasses(): number {
let passes = 0
const setActiveTab = useAppStore.getState().setActiveTab
useAppStore.setState({
setActiveTab: (tabId) => {
passes += 1
if (passes <= MAX_PASSES) {
setActiveTab(tabId)
}
}
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
cleanup = () => {
act(() => root.unmount())
useAppStore.setState({ setActiveTab })
container.remove()
}
act(() => {
root.render(<RepairEffectHarness />)
})
return passes
}
describe('active-terminal repair effect cannot drive a React #185 update loop', () => {
it('settles when the repaired tab is owned by the active worktree', () => {
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabType: 'terminal',
activeTabId: 'stale-tab',
activeTabIdByWorktree: {},
tabsByWorktree: { 'wt-active': [terminalTab('t1', 'wt-active')] },
unifiedTabsByWorktree: {}
})
expect(measureRepairPasses()).toBeLessThan(10)
expect(useAppStore.getState().activeTabId).toBe('t1')
})
it('settles when another worktree reuses the tab id and is scanned first', () => {
// Why regression: first-match ownership skipped activeTabId while reallocating
// activeTabIdByWorktree, retriggering the repair effect indefinitely.
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabType: 'terminal',
activeTabId: 'stale-tab',
activeTabIdByWorktree: {},
tabsByWorktree: {
'wt-other': [terminalTab('t1', 'wt-other')],
'wt-active': [terminalTab('t1', 'wt-active')]
},
unifiedTabsByWorktree: {}
})
expect(measureRepairPasses()).toBeLessThan(10)
// Why: settling by refusing to write would leave the repair permanently
// unsatisfied — quiet, but with activeTabId stuck on a tab that is gone.
expect(useAppStore.getState().activeTabId).toBe('t1')
expect(useAppStore.getState().activeTabIdByWorktree['wt-active']).toBe('t1')
})
it('activates the active worktree unified tab when another worktree reuses the entity id', () => {
const otherTab = unifiedTerminalTab('t1', 't1', 'wt-other', 'g-other')
const otherPreviousTab = unifiedTerminalTab(
'other-previous',
'other-previous',
'wt-other',
'g-other'
)
const activeTab = unifiedTerminalTab('t1', 't1', 'wt-active', 'g-active')
const previousActiveTab = unifiedTerminalTab('u-previous', 't2', 'wt-active', 'g-active')
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabId: 't2',
activeTabIdByWorktree: { 'wt-active': 't2' },
tabsByWorktree: {
'wt-other': [terminalTab('t1', 'wt-other')],
'wt-active': [terminalTab('t1', 'wt-active'), terminalTab('t2', 'wt-active')]
},
unifiedTabsByWorktree: {
'wt-other': [otherTab, otherPreviousTab],
'wt-active': [activeTab, previousActiveTab]
},
groupsByWorktree: {
'wt-other': [
tabGroup('g-other', 'wt-other', otherPreviousTab.id, [otherTab.id, otherPreviousTab.id])
],
'wt-active': [
tabGroup('g-active', 'wt-active', previousActiveTab.id, [
activeTab.id,
previousActiveTab.id
])
]
},
activeGroupIdByWorktree: { 'wt-other': 'g-other', 'wt-active': 'g-active' }
})
act(() => {
useAppStore.getState().setActiveTab('t1')
})
expect(useAppStore.getState().groupsByWorktree['wt-active'][0].activeTabId).toBe(activeTab.id)
expect(useAppStore.getState().groupsByWorktree['wt-other'][0].activeTabId).toBe(
otherPreviousTab.id
)
})
it('keeps unified-only terminal activation as a fallback', () => {
const targetTab = unifiedTerminalTab('u-target', 't1', 'wt-active', 'g-active')
const previousTab = unifiedTerminalTab('u-previous', 't2', 'wt-active', 'g-active')
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabId: null,
activeTabIdByWorktree: {},
tabsByWorktree: {},
unifiedTabsByWorktree: { 'wt-active': [targetTab, previousTab] },
groupsByWorktree: {
'wt-active': [
tabGroup('g-active', 'wt-active', previousTab.id, [targetTab.id, previousTab.id])
]
},
activeGroupIdByWorktree: { 'wt-active': 'g-active' }
})
act(() => {
useAppStore.getState().setActiveTab('t1')
})
expect(useAppStore.getState().groupsByWorktree['wt-active'][0].activeTabId).toBe(targetTab.id)
expect(useAppStore.getState().activeTabId).toBeNull()
})
it('does not reallocate activeTabIdByWorktree when the tab is already active', () => {
// Why: that map is a dependency of both the repair effect and the parked
// watcher sync, so a redundant activation must not re-run either.
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabType: 'terminal',
activeTabId: 't1',
activeTabIdByWorktree: {},
tabsByWorktree: { 'wt-active': [terminalTab('t1', 'wt-active')] },
unifiedTabsByWorktree: {}
})
act(() => {
useAppStore.getState().setActiveTab('t1')
})
const settled = useAppStore.getState().activeTabIdByWorktree
act(() => {
useAppStore.getState().setActiveTab('t1')
})
expect(useAppStore.getState().activeTabIdByWorktree).toBe(settled)
})
it('keeps bell attribution off a background worktree tab', () => {
useAppStore.setState({
activeWorktreeId: 'wt-active',
activeTabType: 'terminal',
activeTabId: 'visible-tab',
activeTabIdByWorktree: {},
tabsByWorktree: {
'wt-active': [terminalTab('visible-tab', 'wt-active')],
'wt-background': [terminalTab('bg-tab', 'wt-background')]
},
unifiedTabsByWorktree: {}
})
act(() => {
useAppStore.getState().setActiveTab('bg-tab')
})
expect(useAppStore.getState().activeTabId).toBe('visible-tab')
expect(useAppStore.getState().activeTabIdByWorktree['wt-background']).toBe('bg-tab')
})
it('records activation for a falsy-but-valid worktree id', () => {
useAppStore.setState({
activeWorktreeId: '',
activeTabId: null,
activeTabIdByWorktree: {},
tabsByWorktree: { '': [terminalTab('t1', '')] },
unifiedTabsByWorktree: {}
})
act(() => {
useAppStore.getState().setActiveTab('t1')
})
expect(useAppStore.getState().activeTabId).toBe('t1')
expect(useAppStore.getState().activeTabIdByWorktree['']).toBe('t1')
})
it('repairs a falsy-but-valid active worktree id through the production hook', () => {
useAppStore.setState({
activeWorktreeId: '',
activeTabType: 'terminal',
activeTabId: 'stale-tab',
activeTabIdByWorktree: { '': 't1' },
tabsByWorktree: { '': [terminalTab('t1', '')] },
unifiedTabsByWorktree: {}
})
expect(measureRepairPasses()).toBeLessThan(10)
expect(useAppStore.getState().activeTabId).toBe('t1')
})
it('does not read inherited unified tabs for a prototype-named owner', () => {
useAppStore.setState({
activeWorktreeId: 'toString',
activeTabId: null,
activeTabIdByWorktree: {},
tabsByWorktree: { toString: [terminalTab('t1', 'toString')] },
unifiedTabsByWorktree: {}
})
expect(() => useAppStore.getState().setActiveTab('t1')).not.toThrow()
expect(useAppStore.getState().activeTabId).toBe('t1')
})
it('activates own unified tabs for a prototype-named owner', () => {
const target = unifiedTerminalTab('t1', 't1', 'toString', 'g-target')
const previous = unifiedTerminalTab('t2', 't2', 'toString', 'g-target')
useAppStore.setState({
activeWorktreeId: 'toString',
activeTabId: 't2',
activeTabIdByWorktree: { toString: 't2' },
tabsByWorktree: {
toString: [terminalTab('t1', 'toString'), terminalTab('t2', 'toString')]
},
unifiedTabsByWorktree: { toString: [target, previous] },
groupsByWorktree: {
toString: [tabGroup('g-target', 'toString', previous.id, [target.id, previous.id])]
},
activeGroupIdByWorktree: { toString: 'g-target' }
})
act(() => useAppStore.getState().setActiveTab('t1'))
expect(useAppStore.getState().groupsByWorktree.toString[0].activeTabId).toBe(target.id)
})
it('does not activate a tab with no owner when no worktree is active', () => {
useAppStore.setState({
activeWorktreeId: null,
activeTabId: null,
activeTabIdByWorktree: {},
tabsByWorktree: {},
unifiedTabsByWorktree: {},
unreadTerminalTabs: { 'missing-tab': true }
})
act(() => {
useAppStore.getState().setActiveTab('missing-tab')
})
expect(useAppStore.getState().activeTabId).toBeNull()
expect(useAppStore.getState().activeTabIdByWorktree).toEqual({})
expect(useAppStore.getState().unreadTerminalTabs['missing-tab']).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import type { TerminalTab, WorkspaceVisibleTabType } from '../../../../shared/types'
import { resolveRepairedActiveTerminalTabId } from './active-terminal-repair'
type ActiveTerminalRepairInput = {
activeTabType: WorkspaceVisibleTabType
activeTabId: string | null
activeTabIdByWorktree: Record<string, string | null>
renderedActiveWorktreeId: string | null
setActiveTab: (tabId: string) => void
tabs: TerminalTab[]
}
export function repairActiveTerminalTab({
activeTabType,
activeTabId,
activeTabIdByWorktree,
renderedActiveWorktreeId,
setActiveTab,
tabs
}: ActiveTerminalRepairInput): boolean {
const rememberedTabId =
renderedActiveWorktreeId !== null &&
Object.hasOwn(activeTabIdByWorktree, renderedActiveWorktreeId)
? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null)
: null
const repairedTabId = resolveRepairedActiveTerminalTabId({
activeTabType,
activeTabId,
rememberedTabId,
tabs
})
if (!repairedTabId) {
return false
}
setActiveTab(repairedTabId)
return true
}
export function useActiveTerminalRepair(input: ActiveTerminalRepairInput): void {
const {
activeTabId,
activeTabIdByWorktree,
activeTabType,
renderedActiveWorktreeId,
setActiveTab,
tabs
} = input
useEffect(() => {
repairActiveTerminalTab(input)
// Why: `tabs` is the dependency so repair reacts to order/content changes, not just scalar ids.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeTabId,
activeTabType,
setActiveTab,
tabs,
activeTabIdByWorktree,
renderedActiveWorktreeId
])
}
@@ -0,0 +1,224 @@
/**
* A direct-SSH snapshot can retain the same tab under old and new worktree IDs
* after a path or repo-ID change. This exercises that hydration path and proves
* active-tab repair converges; it deliberately does not remove the duplicate or
* reproduce React's scheduler-level #185 throw. See PR #11950 for that evidence.
*/
import { describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types'
import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types'
import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers'
import { applyDirectSshRemoteWorkspaceSnapshot } from './remote-workspace-snapshot-apply'
import type { DirectSshSnapshotApplyToken } from './direct-ssh-reconnect-coordinator-types'
import { repairActiveTerminalTab } from '../components/terminal/use-active-terminal-repair'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
const TARGET_ID = 'ssh-target-1'
const OLD_PATH = '/srv/proj/wt'
const NEW_PATH = '/srv/proj/wt-renamed'
const OLD_ID = `repoA::${OLD_PATH}`
const NEW_ID = `repoA::${NEW_PATH}`
// Why a cap and not a while(true): on unfixed code this cycle never terminates.
const MAX_REPAIR_PASSES = 200
const authority: DirectSshAuthority = {
targetId: TARGET_ID,
providerEpoch: 'provider-epoch-1' as SshProviderEpoch,
connectionGeneration: 1
}
function token(snapshotRevision: number): DirectSshSnapshotApplyToken {
return {
authority,
catalogRevision: 0,
repoFingerprint: 'fp',
authorityRequirement: 'required',
snapshotRevision,
outcome: 'complete'
}
}
function snapshot(
revision: number,
worktreePath: string,
tabIds: readonly string[],
activeTabId: string | null
): RemoteWorkspaceSnapshot {
return {
namespace: 'workspace',
revision,
updatedAt: revision,
schemaVersion: 1,
session: {
activeWorktreePath: worktreePath,
activeTabId,
tabsByWorktreePath: {
[worktreePath]: tabIds.map((tabId, index) => ({
id: tabId,
worktreePath,
ptyId: `pty-${tabId}`,
title: `Terminal ${index + 1}`,
customTitle: null,
color: null,
sortOrder: index,
createdAt: index + 1
}))
},
terminalLayoutsByTabId: {},
activeWorktreePathsOnShutdown: [],
activeTabIdByWorktreePath: { [worktreePath]: activeTabId },
remoteSessionIdsByTabId: Object.fromEntries(tabIds.map((id) => [id, `pty-${id}`])),
lastVisitedAtByWorktreePath: { [worktreePath]: revision },
defaultTerminalTabsAppliedByWorktreePath: { [worktreePath]: true }
}
} satisfies RemoteWorkspaceSnapshot
}
type TestStore = ReturnType<typeof createTestStore>
async function applySnapshot(store: TestStore, snap: RemoteWorkspaceSnapshot): Promise<void> {
await applyDirectSshRemoteWorkspaceSnapshot({
store,
snapshot: snap,
token: token(snap.revision),
arrival: 1,
isArrivalCurrent: () => true,
isPreparationTokenCurrent: () => true,
waitForWorkspaceSessionReady: async () => true,
finalizeHydratedTerminals: () => 0
})
}
function worktreeIdsOwningTab(store: TestStore, tabId: string): string[] {
return Object.entries(store.getState().tabsByWorktree)
.filter(([, tabs]) => tabs.some((tab) => tab.id === tabId))
.map(([worktreeId]) => worktreeId)
}
function seedCatalog(store: TestStore, worktreePath: string): void {
store.setState({
worktreesByRepo: {
repoA: [
makeWorktree({
id: `repoA::${worktreePath}`,
repoId: 'repoA',
path: worktreePath,
hostId: `ssh:${TARGET_ID}`
} as never)
]
}
})
}
/**
* One turn of the loop in Terminal.tsx's active-terminal repair effect:
* recompute the repaired id from live state, then activate it. Reports how many
* turns it took to stop and how often `activeTabIdByWorktree` — a declared dep
* of that effect, so a fresh identity re-runs it — was reallocated.
*/
function runRepairCycle(store: TestStore): {
converged: boolean
passes: number
depIdentityChurn: number
} {
let passes = 0
let depIdentityChurn = 0
for (; passes < MAX_REPAIR_PASSES; passes += 1) {
const live = store.getState()
const depsBefore = live.activeTabIdByWorktree
const repaired = repairActiveTerminalTab({
activeTabType: 'terminal',
activeTabId: live.activeTabId,
activeTabIdByWorktree: live.activeTabIdByWorktree,
renderedActiveWorktreeId: live.activeWorktreeId,
setActiveTab: live.setActiveTab,
tabs: live.activeWorktreeId ? (live.tabsByWorktree[live.activeWorktreeId] ?? []) : []
})
if (!repaired) {
return { converged: true, passes, depIdentityChurn }
}
if (store.getState().activeTabIdByWorktree !== depsBefore) {
depIdentityChurn += 1
}
}
return { converged: false, passes, depIdentityChurn }
}
describe('direct-SSH snapshot apply, tab id owned by two worktrees', () => {
it('converges the active-terminal repair instead of re-running it forever', async () => {
const store = createTestStore()
store.setState({
repos: [
{
id: 'repoA',
path: '/srv/proj',
displayName: 'Proj',
badgeColor: '#000',
addedAt: 0,
connectionId: TARGET_ID
} as never
],
// Load-bearing, do not drop: the IPC attach is the only thing stubbed, and
// it leaves behind exactly what a real reconnect leaves behind — one
// registered live PTY per tab. Without that the orphan sweep on the next
// worktree visit treats the duplicated tab as dead, cleans it up, and the
// bug evaporates before the repair effect ever sees it.
reconnectPersistedTerminals: (async () => {
const live = store.getState()
const registered: Record<string, string[]> = { ...live.ptyIdsByTabId }
for (const tabs of Object.values(live.tabsByWorktree)) {
for (const tab of tabs) {
registered[tab.id] = [`pty-${tab.id}`]
}
}
store.setState({ ptyIdsByTabId: registered })
}) as never,
markRemoteWorkspaceHydrated: (() => {}) as never,
setRemoteWorkspaceSyncStatus: (() => {}) as never
})
seedCatalog(store, OLD_PATH)
await applySnapshot(store, snapshot(1, OLD_PATH, ['tab-1', 'tab-2'], 'tab-1'))
store.getState().setActiveWorktree(OLD_ID)
// The worktree is renamed on the host; the catalog re-detects it at the new
// path, so the worktree id changes while the tab ids do not.
seedCatalog(store, NEW_PATH)
await applySnapshot(store, snapshot(2, NEW_PATH, ['tab-1', 'tab-2'], 'tab-1'))
store.getState().setActiveWorktree(NEW_ID)
// The remote deselects; importRemoteWorkspaceSession nulls an activeTabId it
// cannot find among the imported tabs, which is what arms the repair effect.
await applySnapshot(store, snapshot(3, NEW_PATH, ['tab-1', 'tab-2'], null))
// Why assert the precondition and not its removal: the fix stops the owner
// resolver being fooled by the duplicate, it does not remove the duplicate.
// Pinned so the test cannot pass vacuously if hydration stops producing one.
expect(worktreeIdsOwningTab(store, 'tab-1')).toEqual([OLD_ID, NEW_ID])
expect(store.getState().activeTabId).toBeNull()
const repair = runRepairCycle(store)
expect(repair.converged).toBe(true)
expect(repair.passes).toBeLessThanOrEqual(store.getState().tabsByWorktree[NEW_ID].length)
expect(store.getState().activeTabId).toBe('tab-1')
const activeGroupId = store.getState().activeGroupIdByWorktree[NEW_ID]
expect(
store.getState().groupsByWorktree[NEW_ID].find((group) => group.id === activeGroupId)
?.activeTabId
).toBe('tab-1')
// The dep identity settles: re-running the effect body after convergence
// reallocates nothing, so the effect does not schedule itself again.
const settled = runRepairCycle(store)
expect(settled.depIdentityChurn).toBe(0)
expect(settled.passes).toBe(0)
})
})
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalTab } from '../../../../shared/types'
const recordRendererCrashBreadcrumb = vi.fn()
vi.mock('../../lib/crash-breadcrumb-recorder', () => ({
recordRendererCrashBreadcrumb: (...args: unknown[]) => recordRendererCrashBreadcrumb(...args)
}))
const { resolveActiveTabOwnerWorktreeId, _resetDuplicateTabOwnerBreadcrumbsForTests } =
await import('./active-tab-owner-worktree')
function tab(id: string, worktreeId: string): TerminalTab {
return { id, worktreeId, title: id, createdAt: 0, sortOrder: 0 } as unknown as TerminalTab
}
beforeEach(() => {
recordRendererCrashBreadcrumb.mockClear()
_resetDuplicateTabOwnerBreadcrumbsForTests()
})
describe('resolveActiveTabOwnerWorktreeId', () => {
it('returns the sole owner and stays quiet', () => {
const owner = resolveActiveTabOwnerWorktreeId(
{ 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t2', 'wt-b')] },
'wt-a',
't1'
)
expect(owner).toBe('wt-a')
expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled()
})
it('returns null when no worktree owns the tab', () => {
expect(resolveActiveTabOwnerWorktreeId({ 'wt-a': [tab('t1', 'wt-a')] }, 'wt-a', 'gone')).toBe(
null
)
})
it('prefers the active worktree over an earlier-scanned duplicate', () => {
const owner = resolveActiveTabOwnerWorktreeId(
{ 'wt-other': [tab('t1', 'wt-other')], 'wt-active': [tab('t1', 'wt-active')] },
'wt-active',
't1'
)
expect(owner).toBe('wt-active')
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_tab_id_owned_by_multiple_worktrees',
{ ownerCount: 2, resolvedToActiveWorktree: true }
)
})
it('falls back to first match when the active worktree is not an owner', () => {
const owner = resolveActiveTabOwnerWorktreeId(
{ 'wt-x': [tab('t1', 'wt-x')], 'wt-y': [tab('t1', 'wt-y')] },
'wt-active',
't1'
)
expect(owner).toBe('wt-x')
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_tab_id_owned_by_multiple_worktrees',
{ ownerCount: 2, resolvedToActiveWorktree: false }
)
})
// Why: a truthiness guard on the active id would drop this back to first-match.
it('prefers a falsy-but-valid active worktree id', () => {
const owner = resolveActiveTabOwnerWorktreeId(
{ 'wt-other': [tab('t1', 'wt-other')], '': [tab('t1', '')] },
'',
't1'
)
expect(owner).toBe('')
})
it('breadcrumbs a given tab id once per verdict so it cannot flood the ring', () => {
const maps = { 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t1', 'wt-b')] }
for (let i = 0; i < 5; i += 1) {
resolveActiveTabOwnerWorktreeId(maps, 'wt-a', 't1')
}
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(1)
})
// Why: the active worktree changes under a persisting duplicate, and coalescing
// keeps only the newest payload — keyed on the tab id alone, whichever verdict
// a tab reported first would suppress the other for the rest of the session.
it('still reports a non-converging verdict after that tab id reported a converging one', () => {
const maps = { 'wt-other': [tab('t1', 'wt-other')], 'wt-active': [tab('t1', 'wt-active')] }
resolveActiveTabOwnerWorktreeId(maps, 'wt-active', 't1')
resolveActiveTabOwnerWorktreeId(maps, 'wt-third', 't1')
resolveActiveTabOwnerWorktreeId(maps, 'wt-third', 't1')
expect(recordRendererCrashBreadcrumb.mock.calls).toEqual([
[
'terminal_tab_id_owned_by_multiple_worktrees',
{ ownerCount: 2, resolvedToActiveWorktree: true }
],
[
'terminal_tab_id_owned_by_multiple_worktrees',
{ ownerCount: 2, resolvedToActiveWorktree: false }
]
])
})
// Why the count and not just "reports twice": a guard keyed on the active
// worktree id passes the two tests above yet emits once per worktree, which is
// the flood this guard exists to prevent.
it('never exceeds two crumbs for one tab id however the active worktree moves', () => {
const maps = { 'wt-a': [tab('t1', 'wt-a')], 'wt-b': [tab('t1', 'wt-b')] }
const activeWorktreeIds = ['wt-a', 'wt-b', 'wt-c', '', 'wt-d', 'wt-a']
for (let i = 0; i < 600; i += 1) {
resolveActiveTabOwnerWorktreeId(maps, activeWorktreeIds[i % activeWorktreeIds.length], 't1')
}
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(2)
})
// Why: the guard set is never pruned and tab ids are minted per created tab.
it('stops recording once the per-session sample cap is reached', () => {
for (let i = 0; i < 400; i += 1) {
const id = `t-${i}`
const maps = { 'wt-a': [tab(id, 'wt-a')], 'wt-b': [tab(id, 'wt-b')] }
resolveActiveTabOwnerWorktreeId(maps, 'wt-a', id)
resolveActiveTabOwnerWorktreeId(maps, 'wt-c', id)
}
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(256)
})
})
@@ -0,0 +1,82 @@
import type { TerminalTab } from '../../../../shared/types'
import { recordRendererCrashBreadcrumb } from '../../lib/crash-breadcrumb-recorder'
const reportedDuplicateTabVerdicts = new Set<string>()
// Why capped: this set is never pruned and each tab id adds up to two verdict
// keys. 256 keys cover 128–256 duplicated ids, enough evidence for a bundle.
const MAX_REPORTED_DUPLICATE_TAB_VERDICTS = 256
/** Test seam: the duplicate breadcrumb is once-per-tab-id-per-verdict per session. */
export function _resetDuplicateTabOwnerBreadcrumbsForTests(): void {
reportedDuplicateTabVerdicts.clear()
}
/**
* Resolve which worktree owns a terminal tab, preferring the active worktree.
*
* Why the preference: a stale map can leave one tab id under two worktrees, and
* attributing it to an arbitrary first match leaves `activeTabId` permanently
* unconvergeable — which strands Terminal's active-terminal repair effect in a
* self-retriggering loop (React #185).
*/
export function resolveActiveTabOwnerWorktreeId(
tabsByWorktree: Record<string, TerminalTab[]>,
activeWorktreeId: string | null,
tabId: string
): string | null {
let firstOwnerId: string | null = null
let ownerCount = 0
// Why tracked in-loop rather than re-read by key: `tabsByWorktree[activeWorktreeId]`
// resolves inherited members for ids like `toString`, and `?.some` would then throw.
// Why the id and not a boolean: a falsy-but-valid active id ('') would fail a
// truthiness guard below and silently fall back to the first match — the very
// misattribution this function exists to remove.
let activeOwnerId: string | null = null
// Why keys and not entries: entries allocates a pair array per worktree on a path
// that runs per tab activation. Own keys stay safe to index by.
for (const worktreeId of Object.keys(tabsByWorktree)) {
const tabs = tabsByWorktree[worktreeId]
if (!tabs.some((tab) => tab.id === tabId)) {
continue
}
ownerCount += 1
if (firstOwnerId === null) {
firstOwnerId = worktreeId
}
if (worktreeId === activeWorktreeId) {
activeOwnerId = worktreeId
}
}
// Why breadcrumb: hydration can retain duplicates after a worktree id change,
// but current field reports predate this signal.
// Reading it: `ownerCount > 1` is the load-bearing datum; the verdict only
// hints at the caller. A sustained repair loop shows up as `true`, since that
// effect picks from the active worktree's own list — but it does not prove the
// caller, and the repair effect can emit `false` too: its closure holds the
// worktree from its render while this runs against live state, so a worktree
// switch landing in between (an earlier-flushed effect, or IPC before the
// passive flush) reattributes the tab. So `false` covers that race as well as
// a deliberate background activation such as jump-to-agent — discard neither.
// Why the verdict is in the guard key: it flips under a persisting duplicate,
// and coalescing keeps only the newest payload, so one would erase the other.
// Still at most two crumbs per tab id.
const resolvedToActiveWorktree = activeOwnerId !== null
const verdictKey = `${tabId}:${resolvedToActiveWorktree}`
if (
ownerCount > 1 &&
!reportedDuplicateTabVerdicts.has(verdictKey) &&
reportedDuplicateTabVerdicts.size < MAX_REPORTED_DUPLICATE_TAB_VERDICTS
) {
reportedDuplicateTabVerdicts.add(verdictKey)
recordRendererCrashBreadcrumb('terminal_tab_id_owned_by_multiple_worktrees', {
ownerCount,
resolvedToActiveWorktree
})
}
if (ownerCount > 1 && activeOwnerId !== null) {
return activeOwnerId
}
return firstOwnerId
}
+12 -2
View File
@@ -120,7 +120,7 @@ export type TabsSlice = {
entityId: string,
contentType?: TabContentType
) => Tab | null
activateTab: (tabId: string, opts?: { preservePreview?: boolean }) => void
activateTab: (tabId: string, opts?: { preservePreview?: boolean; worktreeId?: string }) => void
closeUnifiedTab: (
tabId: string,
opts?: { recordInteraction?: boolean; terminalRetirementHandled?: boolean }
@@ -826,7 +826,17 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
activateTab: (tabId, opts) => {
set((state) => {
const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId)
const scopedWorktreeId = opts?.worktreeId
let found: ReturnType<typeof findTabAndWorktree>
if (scopedWorktreeId !== undefined) {
const scopedTabs = Object.hasOwn(state.unifiedTabsByWorktree, scopedWorktreeId)
? state.unifiedTabsByWorktree[scopedWorktreeId]
: []
const scopedTab = scopedTabs.find((tab) => tab.id === tabId)
found = scopedTab ? { tab: scopedTab, worktreeId: scopedWorktreeId } : null
} else {
found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId)
}
if (!found) {
return {}
}
+35 -16
View File
@@ -38,6 +38,7 @@ import {
} from '../../../../shared/stable-pane-id'
import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../../../shared/terminal-tab-id'
import { buildByIdIndex, buildWorktreeByIdIndex } from './worktree-by-id-index'
import { resolveActiveTabOwnerWorktreeId } from './active-tab-owner-worktree'
import { isSameCodexRestartNoticeAccount } from './codex-restart-notice-account-identity'
import {
getRepoIdFromWorktreeId,
@@ -1903,17 +1904,18 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
},
setActiveTab: (tabId) => {
let tabOwnerWorktreeId: string | null = null
set((s) => {
// Why: focusing a terminal tab clears its bell, but only for the active worktree — clearing a not-yet-visible background tab (worktree activation / jump-to-agent) would swallow the signal.
let tabOwnerWorktreeId: string | null = null
for (const [wId, tabs] of Object.entries(s.tabsByWorktree)) {
if (tabs.some((t) => t.id === tabId)) {
tabOwnerWorktreeId = wId
break
}
}
tabOwnerWorktreeId = resolveActiveTabOwnerWorktreeId(
s.tabsByWorktree,
s.activeWorktreeId,
tabId
)
const isActiveWorktreeTab =
tabOwnerWorktreeId !== null && tabOwnerWorktreeId === s.activeWorktreeId
const nextUnreadTerminalTabs =
tabOwnerWorktreeId === s.activeWorktreeId && s.unreadTerminalTabs[tabId]
isActiveWorktreeTab && s.unreadTerminalTabs[tabId]
? (() => {
const copy = { ...s.unreadTerminalTabs }
delete copy[tabId]
@@ -1921,20 +1923,37 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})()
: s.unreadTerminalTabs
// Why: only pin global activeTabId to active-worktree tabs — markTerminalTabUnread treats it as "the visible tab" and would swallow BELs on a background tab (e.g. jump-to-agent).
const isActiveWorktreeTab = tabOwnerWorktreeId === s.activeWorktreeId
return {
activeTabId: isActiveWorktreeTab ? tabId : s.activeTabId,
activeTabIdByWorktree: tabOwnerWorktreeId
? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId }
: s.activeTabIdByWorktree,
// Why: a redundant activation must not reallocate this map — Terminal's
// active-terminal repair effect depends on it, so a re-activation that
// can't converge activeTabId (tab id reused by an earlier-scanned
// worktree) would otherwise re-trigger itself into React error #185.
activeTabIdByWorktree:
tabOwnerWorktreeId !== null && s.activeTabIdByWorktree[tabOwnerWorktreeId] !== tabId
? { ...s.activeTabIdByWorktree, [tabOwnerWorktreeId]: tabId }
: s.activeTabIdByWorktree,
unreadTerminalTabs: nextUnreadTerminalTabs
}
})
const item = Object.values(get().unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
const state = get()
const ownerUnifiedTabs =
tabOwnerWorktreeId !== null && Object.hasOwn(state.unifiedTabsByWorktree, tabOwnerWorktreeId)
? state.unifiedTabsByWorktree[tabOwnerWorktreeId]
: []
// Why: a duplicated entity id must activate the same owner chosen above.
const item =
ownerUnifiedTabs.find(
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
) ??
Object.values(state.unifiedTabsByWorktree)
.flat()
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
if (item) {
get().activateTab(item.id)
state.activateTab(
item.id,
tabOwnerWorktreeId !== null ? { worktreeId: tabOwnerWorktreeId } : undefined
)
}
},
+33 -1
View File
@@ -39,9 +39,23 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value)
}
export type GetSpawnArgsForWindowsOptions = {
/**
* GUI launchers (Open In apps) should not leave a lingering Command Prompt.
* `start "" /B` returns immediately and keeps console-subsystem children of
* `.cmd`/`.bat` shims from allocating a fresh visible prompt window.
*
* Opt-in only: `start` re-parses the command line, so callers whose argv can
* carry quoted operands (VS Code `--remote` authorities and remote paths with
* spaces) must leave this off.
*/
detachedGui?: boolean
}
export function getSpawnArgsForWindows(
command: string,
args: string[]
args: string[],
options: GetSpawnArgsForWindowsOptions = {}
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
@@ -51,6 +65,24 @@ export function getSpawnArgsForWindows(
}
// Why: separate argv entries let Node quote spaces without breaking cmd.
if (options.detachedGui) {
// Why: `start` launches a batch target through a nested `cmd /K`, which
// stays resident after the script ends — `/B` only suppresses a *new*
// console, so the shim leaks a hidden cmd.exe. Handing `start` an inner
// `cmd /d /c` makes that interpreter exit with the script.
//
// Window title must be an *empty argv entry* (`''`). libuv's Windows
// quoter turns empty into `""` on the CreateProcess command line — the
// empty title `start` requires so a later quoted path is not eaten as
// the title. The two-character string `'""'` is wrong: libuv re-escapes
// it to `"\"\""`. (Default ComSpec has no spaces, so the bad form often
// still "works"; quoted Program Files paths are where it breaks.)
const cmdExePath = getCmdExePath()
return {
spawnCmd: cmdExePath,
spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args]
}
}
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }