Merge origin/main (v1.4.127-rc.0) into orca-performance

46 commits from main. Conflict resolutions (branch structure wins, main
semantics grafted):

- pending-backlog cap (#7630): main's capPendingPtyData/droppedBacklog
  2MB tail-trim EXCISED — this branch's droppedOutput sentinel mechanism
  (O(1) drop + query salvage + settings-scaled cap + restore marker)
  already bounds the unsent backlog and strictly supersedes it; carrying
  both would race two cap policies over one buffer. Kept main's
  makePtyDataPayload helper and exit-time pending flush (adapted to the
  sentinel). Main's duplicate never-ACK cap test removed (covered by the
  existing sentinel cap tests); its reload-gate tests kept with the
  runtime mock extended.
- replay guard (#7661): main's blind REPLAY_GUARD_RELEASE_FALLBACK_MS
  release dropped — this branch's probe-certified stall release covers
  the same cold-restore reattach lockout without the auto-reply leak a
  timed release can cause in agent TUIs. Main's three regression tests
  KEPT, adapted to probe timing (explicit stallCheckMs).
- hidden-pressure scenario: kept this branch's STRICT 1500ms restore
  budget (the background keep-tail global budget earns it; main relaxed
  to 4s for CI drain overhead) + grafted main's timer-drift gate const.
- ssh-docker-relay-perf: kept this branch's ACK-stalled background PTY
  test AND grafted main's new file-streams+git-churn test (4 tests).
- preload/renderer: droppedBacklog threading excised with the mechanism;
  main's unrelated changes (ssh importConfig reAdopt, etc.) kept.

Gates: typecheck clean; ipc+daemon+terminal-pane+pane-manager suites
4,396 passed / 14 skipped.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-07 15:57:43 -04:00
co-authored by Orca
227 changed files with 11285 additions and 1282 deletions
+26 -1
View File
@@ -13,7 +13,12 @@ import {
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer, agentHookServer, _internals } from './server'
import {
AgentHookServer,
agentHookServer,
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
_internals
} from './server'
import {
AGENT_STATUS_MAX_FIELD_LENGTH,
AGENT_STATUS_STALE_AFTER_MS,
@@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => {
expect(server.getStatusSnapshot()).toEqual([])
})
})
describe('AgentHookServer closed-tab suppression bound', () => {
it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => {
const server = new AgentHookServer()
const internals = server as unknown as {
markTabClosedForAgentStatus: (tabId: string) => void
closedAgentStatusTabIds: Set<string>
}
const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200
for (let i = 0; i < total; i += 1) {
internals.markTabClosedForAgentStatus(`closed-tab-${i}`)
}
// Set stays bounded; oldest ids are evicted, most-recent are retained.
expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX)
expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false)
expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true)
})
})
+16
View File
@@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
// not resurrect on hydrate.
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
// Why: closed-tab suppression only needs to cover recently closed tabs — a
// status event for a long-closed tab cannot arrive once its process/hooks are
// gone. Bound the set so it can't grow one entry per tab close for the whole
// session (it is otherwise only cleared at app quit).
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
type LastStatusFile = {
version: number
entries: Record<string, EnrichedAgentHookEventPayload>
@@ -596,7 +602,17 @@ export class AgentHookServer {
}
private markTabClosedForAgentStatus(tabId: string): void {
// Delete-then-add keeps recently closed tabs most-recent so eviction only
// sheds the oldest ids, which can no longer receive status events.
this.closedAgentStatusTabIds.delete(tabId)
this.closedAgentStatusTabIds.add(tabId)
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
const oldest = this.closedAgentStatusTabIds.keys().next().value
if (oldest === undefined) {
break
}
this.closedAgentStatusTabIds.delete(oldest)
}
}
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
@@ -15,7 +15,7 @@ import {
parseHermesSessionContent
} from './session-scanner-secondary-parsers'
import type { FileWithMtime } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
import { normalizeAgentSessionsDir } from './session-scanner-values'
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
import type {
RemoteParserOptions,
@@ -83,6 +83,7 @@ export function remoteSessionSources(
parseDevinSessionContent
),
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
jsonlSource(
'droid',
remoteHome,
@@ -203,6 +204,15 @@ function piParser(
return parseMessageGraphSessionContent('pi', file, content, platform, options)
}
function ompParser(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: RemoteParserOptions
): Promise<AiVaultSession | null> {
return parseMessageGraphSessionContent('omp', file, content, platform, options)
}
function openClawParser(
file: FileWithMtime,
content: string,
@@ -217,5 +227,9 @@ function remotePathSegments(path: string): string[] {
}
function remotePiSessionsSegments(): string[] {
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
}
function remoteOmpSessionsSegments(): string[] {
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
}
@@ -113,6 +113,7 @@ export function finalizeSession(
resumeCommand: buildAiVaultResumeCommand({
agent: accumulator.agent,
sessionId,
resumeFilePath: accumulator.filePath,
cwd: accumulator.cwd,
platform,
codexHome: options.codexHome
@@ -64,6 +64,8 @@ export async function parseAgentSessionFile(
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
case 'pi':
return parseMessageGraphSessionFile('pi', candidate.file, platform)
case 'omp':
return parseMessageGraphSessionFile('omp', candidate.file, platform)
case 'droid':
return parseDroidSessionFile(candidate.file, platform)
case 'devin':
@@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
ompSessionsDir: join(root, 'omp-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions'),
@@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
return extractContentText(textParts)
}
// Agents whose transcripts are append-only message-graph JSONL (session +
// model_change + message records). OMP is a Pi fork and shares the format.
export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp'
export async function parseMessageGraphSessionFile(
agent: 'openclaw' | 'pi',
agent: MessageGraphAgent,
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
@@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile(
}
export async function parseMessageGraphSessionContent(
agent: 'openclaw' | 'pi',
agent: MessageGraphAgent,
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
@@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
return
}
if (record.type === 'model_change') {
accumulator.model = extractString(record.modelId) ?? accumulator.model
// Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress
// session shows its model before the first assistant reply lands.
accumulator.model =
extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model
return
}
if (record.type !== 'message') {
@@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st
}
export function createMessageGraphSessionResumeState(
agent: 'openclaw' | 'pi',
agent: MessageGraphAgent,
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
@@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState(
}
async function parseMessageGraphSessionLines(args: {
agent: 'openclaw' | 'pi'
agent: MessageGraphAgent
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
@@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture {
}
}
// OMP is a Pi fork sharing the message-graph format, but keys the model on
// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp'
// branch and that model-key difference. (The session `title` is included for
// realism only — the parser derives the title from the first user message.)
export function ompFixture(): IncrementalAgentFixture {
return {
agent: 'omp',
fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl',
seedLines: [
JSON.stringify({
type: 'session',
version: 3,
id: 'cccccccc-dddd-4eee-8fff-000000000000',
cwd: '/repo/app',
title: 'OMP session title',
timestamp: '2026-05-01T10:00:00.000Z'
}),
JSON.stringify({
type: 'model_change',
model: 'gpt-5.4-mini',
timestamp: '2026-05-01T10:00:01.000Z'
}),
JSON.stringify({
type: 'message',
message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] },
timestamp: '2026-05-01T10:00:05.000Z'
})
],
appendLines: [
JSON.stringify({
type: 'message',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'omp incremental answer' }],
model: 'gpt-5.4-mini',
usage: { input: 40, output: 20, totalTokens: 60 }
},
timestamp: '2026-05-01T10:01:00.000Z'
})
],
truncatedLines: [
JSON.stringify({
type: 'session',
id: 'cccccccc-dddd-4eee-8fff-000000000000',
timestamp: '2026-05-01T10:00:00.000Z'
})
]
}
}
export function geminiJsonlFixture(): IncrementalAgentFixture {
return {
agent: 'gemini',
@@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] {
droidFixture(),
openclawFixture(),
piFixture(),
ompFixture(),
geminiJsonlFixture()
]
}
@@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) {
piSessionsDir: join(root, 'pi-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
kimiSessionsDir: join(root, 'kimi-sessions'),
ompSessionsDir: join(root, 'omp-sessions')
}
}
@@ -59,7 +59,8 @@ function resumableStateFactoryFor(
case 'droid':
return () => createDroidSessionResumeState(candidate.file)
case 'openclaw':
case 'pi': {
case 'pi':
case 'omp': {
const agent = candidate.agent
return () => createMessageGraphSessionResumeState(agent, candidate.file)
}
@@ -67,7 +68,12 @@ function resumableStateFactoryFor(
return candidate.file.path.endsWith('.jsonl')
? () => createGeminiJsonlSessionResumeState(candidate.file)
: null
default:
case 'devin':
case 'grok':
case 'hermes':
case 'kimi':
case 'opencode':
case 'rovo':
return null
}
}
@@ -103,7 +109,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void {
/**
* Parse a session file, reusing prior work where the file is provably
* unchanged (mtime+size) and, for append-only JSONL transcripts (Claude,
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the
* Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the
* parse from the last consumed byte when the file only grew. This is what
* keeps the renderer's ~5s forced rescans from re-reading gigabytes of
* transcripts (STA-1278/STA-1417: main process pegging one core during
@@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover
import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources'
import { opencodeDiscoveries } from './session-scanner-opencode-sources'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
import { normalizePiSessionsDir } from './session-scanner-values'
import { normalizeAgentSessionsDir } from './session-scanner-values'
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
@@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join(
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
const PI_SESSIONS_DIR = normalizePiSessionsDir(
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
const PI_SESSIONS_DIR = normalizeAgentSessionsDir(
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'),
'.pi'
)
const OMP_SESSIONS_DIR = normalizeAgentSessionsDir(
process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'),
'.omp'
)
// Why: Devin ATIF transcripts are stored under <DEVIN_HOME>/transcripts.
const DEVIN_TRANSCRIPTS_DIR = join(
@@ -124,7 +129,8 @@ function standardDiscoveries(
...devinDiscoveries(options, wslHomeDirs, limit, issues),
...hermesDiscoveries(options, wslHomeDirs, limit, issues),
...rovoDiscoveries(options, wslHomeDirs, limit, issues),
...piDiscoveries(options, wslHomeDirs, limit, issues)
...piDiscoveries(options, wslHomeDirs, limit, issues),
...ompDiscoveries(options, wslHomeDirs, limit, issues)
]
}
@@ -244,6 +250,21 @@ function piDiscoveries(
)
}
function ompDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise<SessionFileDiscovery>[] {
return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [
'.omp',
'agent',
'sessions'
]).map((rootDir) =>
discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] })
)
}
function openClawDiscovery(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
@@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) {
openclawStateDir: join(root, 'openclaw-state'),
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
piSessionsDir: join(root, 'pi-sessions'),
ompSessionsDir: join(root, 'omp-sessions'),
droidSessionsDir: join(root, 'droid-sessions'),
droidProjectsDir: join(root, 'droid-projects'),
kimiSessionsDir: join(root, 'kimi-sessions')
@@ -25,6 +25,7 @@ export type AiVaultScanOptions = {
openclawStateDir?: string
openclawLegacyStateDir?: string
piSessionsDir?: string
ompSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
kimiSessionsDir?: string
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
extractPreviewContentText,
normalizeAgentSessionsDir,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-values'
@@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => {
expect(result).toBe(`${'a'.repeat(216)}...`)
})
it('expands Pi and OMP agent homes to their session directories', () => {
expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions')
expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions')
expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe(
'/agents/.pi/agent/sessions'
)
expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions')
expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe(
'/agents/.omp/agent/sessions'
)
expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe(
'/agents/.omp/agent/sessions'
)
})
})
+8 -3
View File
@@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null {
return dirname(sessionRoot)
}
export function normalizePiSessionsDir(rawValue: string): string {
// Pi and OMP (a Pi fork) both store transcripts under
// <home>/<agentHomeDirName>/agent/sessions; accept any prefix of that path.
export function normalizeAgentSessionsDir(
rawValue: string,
agentHomeDirName: '.pi' | '.omp'
): string {
const trimmed = rawValue.trim()
if (!trimmed) {
return join(homedir(), '.pi', 'agent', 'sessions')
return join(homedir(), agentHomeDirName, 'agent', 'sessions')
}
const normalized = trimmed.replace(/[\\/]+$/, '')
const leaf = basename(normalized)
@@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string {
if (leaf === 'agent') {
return join(normalized, 'sessions')
}
if (leaf === '.pi') {
if (leaf === agentHomeDirName) {
return join(normalized, 'agent', 'sessions')
}
return normalized
+76 -40
View File
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
@@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => {
])
)
const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl')
await mkdir(roots.ompSessionsDir, { recursive: true })
await writeFile(
ompSessionFile,
jsonLines([
{
type: 'session',
version: 3,
id: 'omp-session',
title: 'OMP session title',
timestamp: '2026-05-01T10:08:30.000Z',
cwd: '/tmp/omp'
},
{
type: 'model_change',
model: 'gpt-5.4-mini',
timestamp: '2026-05-01T10:08:30.500Z'
},
{
type: 'message',
timestamp: '2026-05-01T10:08:31.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] }
},
{
type: 'message',
timestamp: '2026-05-01T10:08:32.000Z',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'OMP answer' }],
model: 'gpt-5.4-mini',
// totalTokens deliberately != input+output so the assertion proves
// the explicit-total field is read, not an input/output sum.
usage: { input: 10, output: 5, totalTokens: 160 }
}
}
])
)
await mkdir(roots.devinTranscriptsDir, { recursive: true })
await writeFile(
join(roots.devinTranscriptsDir, 'devin-session.json'),
@@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => {
"cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'"
)
expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'")
// OMP resumes by absolute transcript path, not by internal session id.
expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`)
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
expect(commandByAgent.get('kimi')).toBe(
"cd '/tmp/kimi' && kimi --session 'session_kimi-session'"
)
const ompSession = result.sessions.find((session) => session.agent === 'omp')
expect(ompSession?.model).toBe('gpt-5.4-mini')
expect(ompSession?.totalTokens).toBe(160)
})
it('captures an in-progress OMP model from model_change before any assistant reply', async () => {
// OMP writes the model on `model_change.model` (not Pi's `modelId`). With no
// assistant message yet, the model must still come through — proving the
// model_change fallback rather than assistant-message capture.
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
await mkdir(roots.ompSessionsDir, { recursive: true })
await writeFile(
join(roots.ompSessionsDir, 'omp-in-progress.jsonl'),
jsonLines([
{
type: 'session',
id: 'omp-in-progress',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/tmp/omp'
},
{ type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' },
{
type: 'message',
timestamp: '2026-05-01T10:00:02.000Z',
message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] }
}
])
)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 })
const session = result.sessions.find((s) => s.agent === 'omp')
expect(session?.model).toBe('omp-mc-only-model')
})
it('strips newline-heavy Grok user_query envelopes without regex matching', async () => {
@@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => {
expect(usedGrokWrapperMatch).toBe(false)
})
})
describe('buildAiVaultResumeCommand', () => {
it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => {
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: 'C:\\Users\\Ada Lovelace\\repo',
platform: 'win32'
})
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
})
it('carries non-default Codex homes in copied resume commands', () => {
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: '/repo/app',
platform: 'darwin',
codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
})
).toBe(
"cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
)
expect(
buildAiVaultResumeCommand({
agent: 'codex',
sessionId: 'session-1',
cwd: 'C:\\Users\\Ada Lovelace\\repo',
platform: 'win32',
codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
})
).toBe(
'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
)
})
})
@@ -533,6 +533,56 @@ describe('CodexRuntimeHomeService', () => {
}
})
it('bridges WSL history from a configured per-distro source-home override', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve())
vi.doMock('../codex/wsl-codex-session-bridge', () => ({
startWslCodexSessionBridgeInBackground
}))
const wslHome = join(testState.userDataDir, 'wsl-home')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => wslHome
}))
const store = createStore(
createSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } },
// Why: the override is a Linux path inside the distro, not <wslHome>/.codex.
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
})
)
try {
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const wslRuntimeHomePath = join(
wslHome,
'.local',
'share',
'orca',
'codex-runtime-home',
'home'
)
service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1)
expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({
distro: 'Ubuntu',
systemCodexHomePath: '/home/me/.config/codex',
managedCodexHomePath: wslRuntimeHomePath
})
} finally {
vi.doUnmock('../codex/wsl-codex-session-bridge')
vi.doUnmock('../wsl')
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
}
})
it('starts WSL session bridging for the distro used by the materialized runtime home', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
@@ -40,6 +40,10 @@ import {
syncSystemCodexResourcesIntoManagedHome
} from '../codex/codex-home-paths'
import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge'
import {
resolveHostCodexSessionSourceHome,
resolveWslCodexSessionSourceHome
} from '../codex/codex-session-source-home'
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
import {
prepareSystemConfigForFreshRuntimeMirror,
@@ -146,7 +150,10 @@ export class CodexRuntimeHomeService {
syncSystemConfigIntoManagedCodexHome()
// Why: historical Codex sessions can be large; bridge them after launch
// setup so starting a fresh Codex TUI never waits on a full tree walk.
void startSystemCodexSessionBridgeInBackground()
void startSystemCodexSessionBridgeInBackground(
{},
resolveHostCodexSessionSourceHome(this.store.getSettings())
)
return this.getRuntimeHomePath()
}
@@ -162,10 +169,11 @@ export class CodexRuntimeHomeService {
if (!distro) {
return
}
const systemCodexHomePath = this.getWslSystemCodexHomePath({
runtime: 'wsl',
wslDistro: distro
})
// Why: history-only override lets custom-CODEX_HOME users bridge from their
// real home; falls back to <wslHome>/.codex, which auth/config still use.
const systemCodexHomePath =
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
return
}
@@ -215,6 +215,51 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => {
).toBe(false)
})
it('bridges from a custom source home override instead of ~/.codex', () => {
// Why: users with a custom CODEX_HOME point history discovery at that
// folder; the default ~/.codex must be ignored when an override is given.
const customSourceHome = join(fakeHomeDir, 'custom-codex')
const customSessionPath = join(
customSourceHome,
'sessions',
'2026',
'05',
'26',
'rollout-custom.jsonl'
)
mkdirSync(dirname(customSessionPath), { recursive: true })
writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8')
// A session under the default ~/.codex should NOT be bridged when overridden.
const defaultSessionPath = join(
getSystemCodexHomePath(),
'sessions',
'2026',
'05',
'26',
'rollout-default.jsonl'
)
mkdirSync(dirname(defaultSessionPath), { recursive: true })
writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8')
syncSystemCodexSessionsIntoManagedHome(customSourceHome)
const bridgedCustomPath = join(
getRuntimeCodexHomePath(),
'sessions',
'2026',
'05',
'26',
'rollout-custom.jsonl'
)
expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n')
expect(
existsSync(
join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl')
)
).toBe(false)
})
it('falls back to symlinks when hardlinks are unavailable', () => {
fsMockState.failLink = true
const systemSessionPath = join(
+11 -6
View File
@@ -42,9 +42,12 @@ let backgroundSessionBridgeTask: Promise<void> | null = null
/**
* Synchronously mirrors system session files into the managed runtime home.
*
* `sourceCodexHomePath` overrides the default ~/.codex history source for users
* who run Codex with a custom CODEX_HOME; it only affects history discovery.
*/
export function syncSystemCodexSessionsIntoManagedHome(): void {
const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void {
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
if (!existsSync(systemSessionsRoot)) {
return
}
@@ -62,12 +65,13 @@ export function syncSystemCodexSessionsIntoManagedHome(): void {
* background bridging without starting duplicate directory walks.
*/
export function startSystemCodexSessionBridgeInBackground(
options: CodexSessionBridgeIncrementalOptions = {}
options: CodexSessionBridgeIncrementalOptions = {},
sourceCodexHomePath?: string
): Promise<void> {
if (backgroundSessionBridgeTask) {
return backgroundSessionBridgeTask
}
const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options)
const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath)
.catch((error: unknown) => {
console.warn('[codex-session-bridge] Background session bridge failed:', error)
})
@@ -88,9 +92,10 @@ export function startSystemCodexSessionBridgeInBackground(
* bridge operation equivalent to the synchronous path.
*/
export async function syncSystemCodexSessionsIntoManagedHomeIncrementally(
options: CodexSessionBridgeIncrementalOptions = {}
options: CodexSessionBridgeIncrementalOptions = {},
sourceCodexHomePath?: string
): Promise<CodexSessionBridgeSummary> {
const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions')
const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions')
if (!existsSync(systemSessionsRoot)) {
return { scannedFiles: 0, linkedFiles: 0 }
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
resolveHostCodexSessionSourceHome,
resolveWslCodexSessionSourceHome
} from './codex-session-source-home'
describe('resolveHostCodexSessionSourceHome', () => {
it('returns undefined when no override is configured', () => {
expect(resolveHostCodexSessionSourceHome({})).toBeUndefined()
expect(resolveHostCodexSessionSourceHome({ codexSessionSourceHome: {} })).toBeUndefined()
})
it('returns undefined for blank/whitespace overrides so the default is kept', () => {
expect(
resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' ' } })
).toBeUndefined()
})
it('returns the trimmed host override path', () => {
expect(
resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' /custom/codex ' } })
).toBe('/custom/codex')
})
})
describe('resolveWslCodexSessionSourceHome', () => {
it('returns undefined when no per-distro override exists', () => {
expect(resolveWslCodexSessionSourceHome({}, 'Ubuntu')).toBeUndefined()
expect(
resolveWslCodexSessionSourceHome(
{ codexSessionSourceHome: { wsl: { Debian: '/home/me/.codex' } } },
'Ubuntu'
)
).toBeUndefined()
})
it('resolves a per-distro override for the matching distro', () => {
expect(
resolveWslCodexSessionSourceHome(
{ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
'Ubuntu'
)
).toBe('/home/me/.config/codex')
})
it('matches distro names case-insensitively, mirroring WSL', () => {
expect(
resolveWslCodexSessionSourceHome(
{ codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } },
'ubuntu'
)
).toBe('/home/me/.config/codex')
})
it('ignores blank per-distro overrides so the default is kept', () => {
expect(
resolveWslCodexSessionSourceHome(
{ codexSessionSourceHome: { wsl: { Ubuntu: ' ' } } },
'Ubuntu'
)
).toBeUndefined()
})
it('does not leak the host override into WSL resolution', () => {
expect(
resolveWslCodexSessionSourceHome(
{ codexSessionSourceHome: { host: '/custom/codex' } },
'Ubuntu'
)
).toBeUndefined()
})
})
@@ -0,0 +1,43 @@
import type { GlobalSettings } from '../../shared/types'
/**
* Resolves the user-configured Codex *session history* source home, if any.
*
* Why: Orca relocates CODEX_HOME to a managed home, then bridges history from
* the user's real Codex home so /resume finds it. That source defaults to
* ~/.codex, but users who run Codex with a custom CODEX_HOME need to point
* history discovery at that folder. This override affects history only; auth,
* config, and hooks continue to read from ~/.codex.
*/
/** Host override; returns undefined to keep the default ~/.codex source. */
export function resolveHostCodexSessionSourceHome(
settings: Pick<GlobalSettings, 'codexSessionSourceHome'>
): string | undefined {
return normalizeSourceHome(settings.codexSessionSourceHome?.host)
}
/** Per-distro WSL override; returns undefined to keep the default <wslHome>/.codex source. */
export function resolveWslCodexSessionSourceHome(
settings: Pick<GlobalSettings, 'codexSessionSourceHome'>,
distro: string
): string | undefined {
const perDistro = settings.codexSessionSourceHome?.wsl
if (!perDistro) {
return undefined
}
// Why: distro keys are matched case-insensitively so "Ubuntu" and "ubuntu"
// resolve the same override, mirroring how WSL treats distro names.
const normalizedDistro = distro.trim().toLowerCase()
for (const [key, value] of Object.entries(perDistro)) {
if (key.trim().toLowerCase() === normalizedDistro) {
return normalizeSourceHome(value)
}
}
return undefined
}
function normalizeSourceHome(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
+10 -6
View File
@@ -122,7 +122,7 @@ import {
_resetMergeQueueCacheForTests,
__resetTrackedUpstreamBranchCacheForTests
} from './client'
import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary'
import { __resetPRConflictSummaryCachesForTests } from './conflict-summary'
import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership'
describe('checkOrcaStarred', () => {
@@ -194,7 +194,7 @@ describe('getPRForBranch', () => {
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetTrackedUpstreamBranchCacheForTests()
__resetPRConflictSummaryGitCapabilityCacheForTests()
__resetPRConflictSummaryCachesForTests()
resetMergedPRCommitMembershipCacheForTest()
})
@@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => {
baseRefOid: 'base-oid',
headRefOid: 'head-oid'
}
// Why a second head OID: identical inputs now hit the summary result
// cache outright; a pushed head re-derives and must still skip the
// unsupported --merge-base retry via the capability cache.
const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } }
const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' }
ghExecFileAsyncMock
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
.mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) })
.mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) })
.mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) })
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
@@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => {
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({ stdout: 'latest-base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({ stdout: '2\n' })
.mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' })
@@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => {
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
_resetMergeQueueCacheForTests()
__resetPRConflictSummaryCachesForTests()
})
it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => {
+150
View File
@@ -0,0 +1,150 @@
import type { PRConflictSummary } from '../../shared/types'
// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR
// (10s mergeability-pending, 2.5s manual-pending) previously each ran a
// network fetch; one fetch per base branch per minute matches the 60s minimum
// background refresh cadence, so the tracked base tip is never staler than the
// PR data around it. Manual refresh intentionally shares the window: GitHub's
// own mergeability recompute is async too, and the card self-corrects within
// a minute.
export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000
// Why bounded: the main process is long-lived; cap the maps so repos and PRs
// that stop refreshing can't accumulate entries forever.
const BASE_OID_CACHE_MAX = 64
const SUMMARY_CACHE_MAX = 128
export type FreshBaseTipResolution =
| { kind: 'resolved'; oid: string }
| { kind: 'fallback-unresolved' }
type CachedBaseTipResolution = {
oid: string | null
resolvedAt: number
}
type CachedSummary = {
value: PRConflictSummary | undefined
// Why: a successful summary is a pure function of two immutable commit OIDs
// and never goes stale; a failed derivation depends on which objects exist
// locally, which the next fetch window can change, so it carries an expiry.
staleAt: number | null
}
const baseOidCache = new Map<string, CachedBaseTipResolution>()
const summaryCache = new Map<string, CachedSummary>()
const inFlightBaseOidResolves = new Map<string, Promise<FreshBaseTipResolution>>()
const inFlightSummaryDerivations = new Map<string, Promise<PRConflictSummary | undefined>>()
// Why: WSL distros have their own git binary, filesystem view, and remote
// access, so cached state must never leak across the host/distro boundary.
export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string {
return wslDistro ? `wsl:${wslDistro}` : 'local:host'
}
// Why JSON: repo paths and git ref names may contain any printable joiner
// character (git allows `|` in branch names), so a delimiter-joined key could
// alias distinct identities onto one cache entry.
export function buildConflictSummaryCacheKey(...parts: string[]): string {
return JSON.stringify(parts)
}
export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null {
const entry = baseOidCache.get(baseKey)
if (!entry) {
return null
}
if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) {
baseOidCache.delete(baseKey)
return null
}
return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' }
}
export function storeResolvedBaseTip(baseKey: string, oid: string): void {
setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX)
}
export function rememberUnresolvedBaseTip(baseKey: string): void {
setBoundedMapEntry(
baseOidCache,
baseKey,
{ oid: null, resolvedAt: Date.now() },
BASE_OID_CACHE_MAX
)
}
export function readCachedSummary(summaryKey: string): CachedSummary | null {
const entry = summaryCache.get(summaryKey)
if (!entry) {
return null
}
if (entry.staleAt !== null && Date.now() >= entry.staleAt) {
summaryCache.delete(summaryKey)
return null
}
return entry
}
export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void {
setBoundedMapEntry(
summaryCache,
summaryKey,
{
value,
staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null
},
SUMMARY_CACHE_MAX
)
}
export function dedupeBaseOidResolve(
key: string,
factory: () => Promise<FreshBaseTipResolution>
): Promise<FreshBaseTipResolution> {
return dedupeInFlight(inFlightBaseOidResolves, key, factory)
}
export function dedupeSummaryDerivation(
key: string,
factory: () => Promise<PRConflictSummary | undefined>
): Promise<PRConflictSummary | undefined> {
return dedupeInFlight(inFlightSummaryDerivations, key, factory)
}
function dedupeInFlight<T>(
map: Map<string, Promise<T>>,
key: string,
factory: () => Promise<T>
): Promise<T> {
const existing = map.get(key)
if (existing) {
return existing
}
const promise = factory().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
function setBoundedMapEntry<K, V>(map: Map<K, V>, key: K, value: V, maxEntries: number): void {
if (map.has(key)) {
map.delete(key)
}
map.set(key, value)
while (map.size > maxEntries) {
const oldest = map.keys().next()
if (oldest.done) {
return
}
map.delete(oldest.value)
}
}
export function __resetPRConflictSummaryDerivationCachesForTests(): void {
baseOidCache.clear()
summaryCache.clear()
inFlightBaseOidResolves.clear()
inFlightSummaryDerivations.clear()
}
+331
View File
@@ -0,0 +1,331 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Why these tests exist: the conflict-summary derivation used to re-run a
// network `git fetch` plus a four-subprocess chain on every PR refresh tick
// (measured ~490 full derivations in 2.1h on one machine). They pin the
// regression contract by counting subprocess spawns: unchanged inputs must
// cost zero subprocesses, and the base fetch must run at most once per
// throttle window.
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache'
import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary'
type GitResult = { stdout: string }
type GitHandler = (argv: string[]) => Promise<GitResult>
const defaultHandlers: Record<string, GitHandler> = {
fetch: async () => ({ stdout: '' }),
'rev-parse': async () => ({ stdout: 'base-tip-1\n' }),
'merge-base': async () => ({ stdout: 'merge-base-1\n' }),
'rev-list': async () => ({ stdout: '3\n' }),
'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' })
}
function mockGitDispatch(overrides: Record<string, GitHandler> = {}): void {
gitExecFileAsyncMock.mockImplementation((argv: string[]) => {
const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]]
if (!handler) {
return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`))
}
return handler(argv)
})
}
function spawnCount(command?: string): number {
const calls = gitExecFileAsyncMock.mock.calls
if (!command) {
return calls.length
}
return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length
}
const expectedSummary = {
baseRef: 'main',
baseCommit: 'base-ti',
commitsBehind: 3,
files: ['src/conflict.ts']
}
function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) {
return getPRConflictSummary(
'/repo-root',
'main',
'github-base-oid',
headRefOid,
wslDistro ? { wslDistro } : {}
)
}
describe('getPRConflictSummary caching', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_750_000_000_000)
gitExecFileAsyncMock.mockReset()
__resetPRConflictSummaryCachesForTests()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => {
mockGitDispatch()
const first = await deriveSummary()
expect(first).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount()).toBe(5)
const second = await deriveSummary()
const third = await deriveSummary()
expect(second).toEqual(expectedSummary)
expect(third).toEqual(expectedSummary)
expect(spawnCount()).toBe(5)
})
it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => {
mockGitDispatch()
await deriveSummary('head-oid-1')
const afterPush = await deriveSummary('head-oid-2')
expect(afterPush).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
expect(spawnCount('rev-list')).toBe(2)
expect(spawnCount('merge-tree')).toBe(2)
})
it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => {
mockGitDispatch()
await deriveSummary()
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
const afterExpiry = await deriveSummary()
expect(afterExpiry).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('rev-parse')).toBe(2)
// Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits.
expect(spawnCount('merge-base')).toBe(1)
expect(spawnCount('rev-list')).toBe(1)
expect(spawnCount('merge-tree')).toBe(1)
})
it('re-derives after the throttle window when the base tip moved', async () => {
mockGitDispatch()
await deriveSummary()
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
mockGitDispatch({
'rev-parse': async () => ({ stdout: 'base-tip-2\n' }),
'rev-list': async () => ({ stdout: '5\n' })
})
const afterBaseMove = await deriveSummary()
expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 })
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('merge-base')).toBe(2)
expect(spawnCount('rev-list')).toBe(2)
expect(spawnCount('merge-tree')).toBe(2)
})
it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const firstCall = deriveSummary()
const secondCall = deriveSummary()
releaseFetch?.({ stdout: '' })
const [first, second] = await Promise.all([firstCall, secondCall])
expect(first).toEqual(expectedSummary)
expect(second).toEqual(expectedSummary)
expect(spawnCount()).toBe(5)
})
it('dedupes overlapping derivations after resolving the same live base tip', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const backgroundRefresh = getPRConflictSummary(
'/repo-root',
'main',
'older-github-base-oid',
'head-oid-1',
{}
)
const manualRefresh = getPRConflictSummary(
'/repo-root',
'main',
'newer-github-base-oid',
'head-oid-1',
{}
)
releaseFetch?.({ stdout: '' })
const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh])
expect(backgroundSummary).toEqual(expectedSummary)
expect(manualSummary).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(1)
expect(spawnCount('rev-list')).toBe(1)
expect(spawnCount('merge-tree')).toBe(1)
})
it('shares one base fetch across concurrent PRs on the same base branch', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
})
})
const prA = deriveSummary('head-oid-a')
const prB = deriveSummary('head-oid-b')
releaseFetch?.({ stdout: '' })
const [summaryA, summaryB] = await Promise.all([prA, prB])
expect(summaryA).toEqual(expectedSummary)
expect(summaryB).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('rev-parse')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
})
it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => {
let releaseFetch: ((value: GitResult) => void) | undefined
mockGitDispatch({
fetch: () =>
new Promise<GitResult>((resolve) => {
releaseFetch = resolve
}),
'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')),
'rev-list': async (argv) => ({
stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n'
}),
'merge-tree': async (argv) => ({
stdout: argv.includes('bbbb2222-base')
? 'tree-oid\u0000src/b.ts\u0000'
: 'tree-oid\u0000src/a.ts\u0000'
})
})
const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {})
const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {})
releaseFetch?.({ stdout: '' })
const [summaryA, summaryB] = await Promise.all([prA, prB])
expect(summaryA).toEqual({
baseRef: 'main',
baseCommit: 'aaaa111',
commitsBehind: 4,
files: ['src/a.ts']
})
expect(summaryB).toEqual({
baseRef: 'main',
baseCommit: 'bbbb222',
commitsBehind: 7,
files: ['src/b.ts']
})
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount('merge-base')).toBe(2)
})
it('falls back to the local remote-tracking ref when fetch fails', async () => {
mockGitDispatch({
fetch: () => Promise.reject(new Error('offline'))
})
const summary = await deriveSummary()
expect(summary).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
// Why: a failed fetch attempt must also be throttled — offline machines
// were the worst case, paying the 10s fetch timeout on every tick.
const repeat = await deriveSummary()
expect(repeat).toEqual(expectedSummary)
expect(spawnCount('fetch')).toBe(1)
expect(spawnCount()).toBe(5)
})
it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => {
mockGitDispatch({
fetch: () => Promise.reject(new Error('offline')),
'rev-parse': () => Promise.reject(new Error('unknown revision'))
})
const summary = await deriveSummary()
expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' })
expect(
gitExecFileAsyncMock.mock.calls.some(
([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid')
)
).toBe(true)
})
it('keeps WSL-distro derivations isolated from host derivations', async () => {
mockGitDispatch()
await deriveSummary()
await deriveSummary('head-oid-1', 'Ubuntu')
expect(spawnCount('fetch')).toBe(2)
expect(
gitExecFileAsyncMock.mock.calls.filter(
([argv, options]) =>
argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu'
)
).toHaveLength(1)
})
it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
mockGitDispatch()
// Why: these two calls alias under naive pipe-joined keys — the segment
// boundary shifts between repoPath and baseRefName.
await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {})
await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {})
expect(spawnCount('fetch')).toBe(2)
expect(spawnCount('merge-base')).toBe(2)
})
it('caches failed derivations only until the throttle window expires', async () => {
mockGitDispatch({
'merge-base': () => Promise.reject(new Error('bad object'))
})
await expect(deriveSummary()).resolves.toBeUndefined()
const failureSpawns = spawnCount()
// Within the window the failure is negative-cached: zero subprocesses.
await expect(deriveSummary()).resolves.toBeUndefined()
expect(spawnCount()).toBe(failureSpawns)
// After expiry the derivation retries (a fetch may have brought objects in).
vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000)
mockGitDispatch()
await expect(deriveSummary()).resolves.toEqual(expectedSummary)
expect(spawnCount('merge-base')).toBe(2)
})
})
+106 -20
View File
@@ -1,5 +1,17 @@
import type { PRConflictSummary } from '../../shared/types'
import { gitExecFileAsync } from '../git/runner'
import {
__resetPRConflictSummaryDerivationCachesForTests,
buildConflictSummaryCacheKey,
dedupeBaseOidResolve,
dedupeSummaryDerivation,
getConflictSummaryGitRuntimeKey,
readCachedSummary,
readFreshBaseTipResolution,
rememberUnresolvedBaseTip,
storeResolvedBaseTip,
storeCachedSummary
} from './conflict-summary-cache'
type LocalGitExecOptions = {
wslDistro?: string
@@ -7,8 +19,9 @@ type LocalGitExecOptions = {
const mergeTreeMergeBaseUnsupportedRuntimes = new Set<string>()
export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void {
export function __resetPRConflictSummaryCachesForTests(): void {
mergeTreeMergeBaseUnsupportedRuntimes.clear()
__resetPRConflictSummaryDerivationCachesForTests()
}
export async function getPRConflictSummary(
@@ -18,45 +31,122 @@ export async function getPRConflictSummary(
headRefOid: string,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRConflictSummary | undefined> {
try {
// Why: the renderer only needs a read-only merge-conflict snapshot. We
// derive it from local git state so the PR card can show GitHub-style
// detail without spending additional gh API calls on every refresh. We use
// GitHub's head OID directly because the registered repo path may not have
// a matching local branch name for the PR head. For the base side, prefer a
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
// which compares against the latest base branch tip rather than the PR's
// older pinned baseRefOid snapshot.
const latestBaseOid = await resolveLatestBaseOid(
// Why: the renderer only needs a read-only merge-conflict snapshot. We
// derive it from local git state so the PR card can show GitHub-style
// detail without spending additional gh API calls on every refresh. We use
// GitHub's head OID directly because the registered repo path may not have
// a matching local branch name for the PR head. For the base side, prefer a
// freshly-fetched remote-tracking ref so Orca matches GitHub's portal,
// which compares against the latest base branch tip rather than the PR's
// older pinned baseRefOid snapshot.
const latestBaseOid = await resolveLatestBaseOidThrottled(
repoPath,
baseRefName,
baseRefOid,
localGitOptions
)
// Why: the summary is a pure function of the two commit OIDs, so a key hit
// can skip the whole merge-base/rev-list/merge-tree subprocess chain.
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const summaryKey = buildConflictSummaryCacheKey(
runtimeKey,
repoPath,
baseRefName,
headRefOid,
latestBaseOid
)
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
// Why: different GitHub reads can report different pinned baseRefOid values
// while still resolving to the same live base tip; dedupe the expensive
// local derivation on the actual summary identity.
return dedupeSummaryDerivation(summaryKey, () =>
derivePRConflictSummary(
repoPath,
baseRefName,
baseRefOid,
headRefOid,
latestBaseOid,
summaryKey,
localGitOptions
)
)
}
async function derivePRConflictSummary(
repoPath: string,
baseRefName: string,
headRefOid: string,
latestBaseOid: string,
summaryKey: string,
localGitOptions: LocalGitExecOptions
): Promise<PRConflictSummary | undefined> {
const cached = readCachedSummary(summaryKey)
if (cached) {
return cached.value
}
try {
const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions)
const [commitsBehind, files] = await Promise.all([
countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions),
loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions)
])
return {
const summary = {
baseRef: baseRefName,
baseCommit: latestBaseOid.slice(0, 7),
commitsBehind,
files,
...(files.length === 0 ? { localMergeState: 'clean' as const } : {})
}
storeCachedSummary(summaryKey, summary)
return summary
} catch {
storeCachedSummary(summaryKey, undefined)
return undefined
}
}
async function resolveLatestBaseOid(
async function resolveLatestBaseOidThrottled(
repoPath: string,
baseRefName: string,
fallbackBaseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string> {
const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName)
const cachedResolution = readFreshBaseTipResolution(baseKey)
if (cachedResolution) {
return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid
}
return dedupeBaseOidResolve(baseKey, async () => {
// Why re-check inside the dedupe slot: a sibling caller may have finished
// resolving between our cache read and this factory starting.
const freshResolution = readFreshBaseTipResolution(baseKey)
if (freshResolution) {
return freshResolution
}
const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions)
if (oid) {
storeResolvedBaseTip(baseKey, oid)
return { kind: 'resolved', oid }
}
// Why cache the unresolved probe, not the caller fallback: the fetch
// attempt is branch-wide expensive work, but GitHub's baseRefOid is
// PR-specific and must not leak to sibling PRs on the same base branch.
rememberUnresolvedBaseTip(baseKey)
return { kind: 'fallback-unresolved' }
}).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid))
}
async function resolveLatestBaseOid(
repoPath: string,
baseRefName: string,
localGitOptions: LocalGitExecOptions
): Promise<string | null> {
const remoteName = 'origin'
try {
@@ -88,7 +178,7 @@ async function resolveLatestBaseOid(
}
}
return fallbackBaseOid
return null
}
async function resolveMergeBase(
@@ -123,7 +213,7 @@ async function loadConflictingFiles(
baseOid: string,
localGitOptions: LocalGitExecOptions
): Promise<string[]> {
const capabilityKey = getMergeTreeCapabilityKey(localGitOptions)
const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro)
const modernArgs = [
'merge-tree',
'--write-tree',
@@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree(
}
}
function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string {
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host'
}
function parseMergeTreeNameOnlyOutput(stdout: string): string[] {
const entries = stdout.split('\0').filter(Boolean)
if (entries.length === 0) {
+53 -14
View File
@@ -3,14 +3,19 @@ request timestamps, and follow-up scheduling against shared module state. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types'
const { sendMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock } = vi.hoisted(
() => ({
sendMock: vi.fn(),
getAllWebContentsMock: vi.fn(),
getPRForBranchOutcomeMock: vi.fn(),
getRateLimitMock: vi.fn()
})
)
const {
sendMock,
getAllWebContentsMock,
getPRForBranchOutcomeMock,
getRateLimitMock,
rateLimitGuardMock
} = vi.hoisted(() => ({
sendMock: vi.fn(),
getAllWebContentsMock: vi.fn(),
getPRForBranchOutcomeMock: vi.fn(),
getRateLimitMock: vi.fn(),
rateLimitGuardMock: vi.fn()
}))
vi.mock('electron', () => ({
webContents: {
@@ -25,7 +30,7 @@ vi.mock('./client', () => ({
vi.mock('./rate-limit', () => ({
getRateLimit: getRateLimitMock,
noteRateLimitSpend: vi.fn(),
rateLimitGuard: vi.fn(() => ({ blocked: false }))
rateLimitGuard: rateLimitGuardMock
}))
function makeCandidate(
@@ -80,6 +85,8 @@ describe('pr-refresh-coordinator', () => {
getAllWebContentsMock.mockReset()
getPRForBranchOutcomeMock.mockReset()
getRateLimitMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
getAllWebContentsMock.mockReturnValue([
{
id: 1,
@@ -505,13 +512,45 @@ describe('pr-refresh-coordinator', () => {
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5)
})
it('proceeds with background refreshes when the budget probe fails (fail open)', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
// Why: regression for #7553 — GHES with rate limiting disabled 404s every
// probe; an unreadable budget must not pause refreshes.
getRateLimitMock.mockResolvedValue({
ok: false,
error: 'HTTP 404: Rate limiting is not enabled.'
})
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'no-pr',
fetchedAt: Date.now()
})
enqueuePRRefresh(makeCandidate(), 'active', 80, 1)
await vi.advanceTimersByTimeAsync(0)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1)
const pausedEvents = sendMock.mock.calls
.map(([, event]) => event)
.filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit')
expect(pausedEvents).toHaveLength(0)
})
it('does not consume active burst slots for rate-limit pauses', async () => {
const { enqueuePRRefresh } = await import('./pr-refresh-coordinator')
getRateLimitMock
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false })
.mockResolvedValueOnce({ ok: false })
.mockResolvedValue({ ok: true })
// Why: keyed on drained items (one getRateLimit call each) rather than
// guard-call counts, so the guard's per-bucket evaluation order stays free
// to change without breaking the slot-accounting assertion.
let drainedItems = 0
getRateLimitMock.mockImplementation(async () => {
drainedItems += 1
return { ok: true }
})
rateLimitGuardMock.mockImplementation(() =>
drainedItems <= 3
? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 }
: { blocked: false }
)
getPRForBranchOutcomeMock.mockResolvedValue({
kind: 'upstream-error',
errorType: 'unknown',
+5 -14
View File
@@ -708,20 +708,11 @@ async function drainQueue(): Promise<void> {
)
if (isBackground(next.reason)) {
const rateLimit = await getRateLimit()
if (!rateLimit.ok) {
const retryAt = Date.now() + 30_000
queue.set(next.key, { ...next, dueAt: retryAt })
broadcast({
aliases,
reason: next.reason,
status: 'paused',
pausedUntil: retryAt,
skippedReason: 'rate-limit'
})
scheduleDrain(30_000)
continue
}
// Why: the probe only warms rateLimitGuard's cached snapshot, so a
// failed probe must fail open — GHES with rate limiting disabled 404s
// every probe (#7553). A genuinely broken gh surfaces per-key as typed
// fetch outcomes instead (visible keys additionally back off).
await getRateLimit()
const buckets = backgroundRefreshBuckets()
const blockedGuard = buckets
.map((bucket) => rateLimitGuard(bucket))
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
ghExecFileAsync: ghExecFileAsyncMock
}))
vi.mock('./gh-utils', () => ({
acquire: vi.fn(),
release: vi.fn()
}))
import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit'
const PAYLOAD = JSON.stringify({
resources: {
core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 },
search: { limit: 30, remaining: 28, reset: 1_700_000_000 },
graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 }
}
})
describe('getRateLimit', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
ghExecFileAsyncMock.mockReset()
_resetRateLimitCache()
})
afterEach(() => {
vi.useRealTimers()
})
it('parses bucket counts from gh api rate_limit', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
const result = await getRateLimit()
expect(result).toEqual({
ok: true,
snapshot: {
core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 },
search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 },
graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 },
fetchedAt: 1_000
}
})
})
it('caches a failed probe for the TTL instead of re-spawning gh', async () => {
// Why: regression for #7553 — GHES with rate limiting disabled fails every
// probe. Refreshes fail open past the failure, so without a negative cache
// each queued refresh would spawn its own doomed gh subprocess.
ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.'))
const first = await getRateLimit()
const second = await getRateLimit()
expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' })
expect(second).toEqual(first)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('re-probes after the failure TTL elapses and recovers on success', async () => {
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
expect((await getRateLimit()).ok).toBe(false)
expect((await getRateLimit()).ok).toBe(false)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
vi.setSystemTime(1_000 + 30_000)
expect((await getRateLimit()).ok).toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('bypasses the failure cache when forced', async () => {
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom'))
ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD })
expect((await getRateLimit()).ok).toBe(false)
expect((await getRateLimit({ force: true })).ok).toBe(true)
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('rateLimitGuard', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
ghExecFileAsyncMock.mockReset()
_resetRateLimitCache()
})
afterEach(() => {
vi.useRealTimers()
})
async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise<void> {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
resources: {
core: { limit: 5000, remaining: 3, reset: resetEpochSeconds },
search: { limit: 30, remaining: 28, reset: resetEpochSeconds },
graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds }
}
})
})
expect((await getRateLimit()).ok).toBe(true)
}
it('blocks below the floor while resetAt is in the future', async () => {
await cacheExhaustedSnapshot(61)
expect(rateLimitGuard('core')).toEqual({
blocked: true,
remaining: 3,
limit: 5000,
resetAt: 61
})
})
it('fails open once the blocking bucket resetAt has elapsed', async () => {
// Why: probes can fail indefinitely (sleep past resetAt, network blip), so
// a stale exhausted snapshot must not block once GitHub has already reset
// the budget — a past-due pause would spin the coordinator drain loop.
await cacheExhaustedSnapshot(61)
vi.setSystemTime(61_000)
expect(rateLimitGuard('core')).toEqual({ blocked: false })
})
})
+11
View File
@@ -35,6 +35,11 @@ import {
// 1-per-second "is it safe now?" polling pattern UIs tend to fall into.
const RATE_LIMIT_CACHE_TTL_MS = 30_000
let cached: GitHubRateLimitSnapshot | null = null
// Why: failed probes are cached for the same TTL as successes. Refreshes fail
// open past a failed probe, so on a host that can never report a budget (GHES
// with rate limiting disabled 404s every probe) an uncached failure would cost
// a gh subprocess per queued refresh.
let probeFailure: { at: number; error: string } | null = null
type GhRateLimitPayload = {
resources?: {
@@ -66,6 +71,7 @@ function parseBucket(
/** @internal — test-only */
export function _resetRateLimitCache(): void {
cached = null
probeFailure = null
}
// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new
@@ -196,6 +202,9 @@ export async function getRateLimit(options?: { force?: boolean }): Promise<GetRa
if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) {
return { ok: true, snapshot: cached }
}
if (!options?.force && probeFailure && Date.now() - probeFailure.at < RATE_LIMIT_CACHE_TTL_MS) {
return { ok: false, error: probeFailure.error }
}
if (!options?.force && probeInFlight) {
return probeInFlight
}
@@ -222,9 +231,11 @@ async function fetchRateLimitSnapshot(): Promise<GetRateLimitResult> {
fetchedAt: Date.now()
}
cached = snapshot
probeFailure = null
return { ok: true, snapshot }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
probeFailure = { at: Date.now(), error: message }
return { ok: false, error: message }
} finally {
release()
+42
View File
@@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types'
import {
AUTHORIZED_EXTERNAL_PATHS_MAX,
authorizeExternalPath,
invalidateAuthorizedRootsCache,
isDescendantOrEqual,
isPathAllowed,
rebuildAuthorizedRootsCache,
resolveAuthorizedPath,
resolveRegisteredWorktreePath,
@@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => {
}
})
})
describe('filesystem-auth authorized external path bound', () => {
// Empty allow-list store, so a path is allowed only if it (or an ancestor) is
// in the session-authorized external-path set.
const emptyStore = makeStore([])
const flood = (n: number): string =>
resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)
it('bounds the authorized external path set with LRU eviction', () => {
const keep = resolve('/leak-audit-ext/keep')
authorizeExternalPath(keep)
// Flood past the cap with distinct external paths, re-authorizing `keep`
// periodically so LRU keeps it hot.
const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
for (let i = 0; i < total; i += 1) {
authorizeExternalPath(flood(i))
if (i % 250 === 0) {
authorizeExternalPath(keep)
}
}
// The oldest never-re-touched entries fell out of the bounded set...
expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
// ...while the periodically re-authorized path and the most recent survive.
expect(isPathAllowed(keep, emptyStore)).toBe(true)
expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
})
it('re-authorizes an evicted path on next use (self-healing)', () => {
const path = resolve('/leak-audit-ext/evicted-then-reused')
for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
authorizeExternalPath(flood(100_000 + i))
}
expect(isPathAllowed(path, emptyStore)).toBe(false)
authorizeExternalPath(path)
expect(isPathAllowed(path, emptyStore)).toBe(true)
})
})
+24 -2
View File
@@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
export const PATH_ACCESS_DENIED_MESSAGE =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate for the whole session (file drops,
// terminal link opens, editor/composer/notebook opens). Bound the set with LRU
// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long
// session touching many distinct external files cannot grow it (or the O(n) auth
// scan in isPathAllowed) without limit. Every caller re-authorizes a path right
// before operating on it, so evicting a stale entry is self-healing and does not
// weaken the security boundary.
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set<string>()
const registeredWorktreeRoots = new Set<string>()
const registeredWorktreeRootsByRepo = new Map<string, Set<string>>()
@@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8
type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
function rememberAuthorizedExternalPath(path: string): void {
// Delete-then-add keeps re-authorized (actively used) paths most-recent so
// eviction only sheds the oldest never-re-touched entries.
authorizedExternalPaths.delete(path)
authorizedExternalPaths.add(path)
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
const oldest = authorizedExternalPaths.keys().next().value
if (oldest === undefined) {
break
}
authorizedExternalPaths.delete(oldest)
}
}
export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
authorizedExternalPaths.add(resolvedTarget)
rememberAuthorizedExternalPath(resolvedTarget)
try {
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
authorizedExternalPaths.add(realpathSync(resolvedTarget))
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
} catch {}
}
+53
View File
@@ -3381,6 +3381,54 @@ describe('registerPtyHandlers', () => {
}
})
// Why: the cap and its flag must never fire in the common case (renderer keeps
// up), so ordinary small output carries no droppedBacklog.
it('does not flag droppedBacklog for ordinary small output under the cap', async () => {
vi.useFakeTimers()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 12),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'),
registerPreAllocatedHandleForPty: vi.fn()
}
try {
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
undefined,
{ awaitLocalPtyStartup: () => Promise.resolve() }
)
const pendingSpawn = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'small-output-session'
}) as Promise<{ id: string }>
await Promise.resolve()
const daemon = installObservableDaemonTestProvider()
rebindLocalProviderListeners()
const result = await pendingSpawn
daemon.emitData(result.id, 'small output')
await vi.advanceTimersByTimeAsync(50)
const dataSends = mainWindow.webContents.send.mock.calls.filter(
(call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id
)
expect(dataSends.length).toBeGreaterThan(0)
for (const call of dataSends) {
expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined()
}
} finally {
vi.useRealTimers()
}
})
it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => {
const barrier = makeDeferred()
const runtime = {
@@ -8380,6 +8428,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8797,6 +8846,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8853,6 +8903,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -8916,6 +8967,7 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
@@ -10332,6 +10384,7 @@ describe('registerPtyHandlers', () => {
onPtyData: vi.fn(),
preAllocateHandleForPty: vi.fn(() => null),
getPtyOutputSequence: vi.fn(() => 2_472),
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({
data: 'snapshot\r\n',
cols: 100,
+27 -9
View File
@@ -1504,6 +1504,11 @@ export function registerPtyHandlers(
data: string
startSeq?: number
containsBackgroundOutput?: boolean
// Why droppedOutput (not main's droppedBacklog trim): this branch bounds
// the unsent backlog with the O(1) drop-to-sentinel + query-salvage +
// snapshot-restore mechanism below, which strictly supersedes main's
// #7630 keep-2MB-tail trim — carrying both would race two cap policies
// over the same buffer.
droppedOutput?: true
}
@@ -1552,7 +1557,9 @@ export function registerPtyHandlers(
const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024
// Why: while the renderer cannot receive (frozen, starved, mid-reload), a
// chatty PTY used to grow its pendingData string without bound — main-process
// heap ballooning that a renderer reload cannot clear. Beyond this cap the
// heap ballooning that a renderer reload cannot clear (main's #7630 fixed the
// same Win/Linux background-throttled-renderer leak with a 2MB tail trim;
// this branch's sentinel mechanism supersedes it). Beyond this cap the
// buffered bytes are dropped and the pane heals from the main-owned buffer
// snapshot via the droppedOutput sentinel (renderer hidden-output restore).
// Why read settings live: the cap scales with the user's scrollback setting
@@ -2339,15 +2346,25 @@ export function registerPtyHandlers(
// tears down the terminal on pty:exit before the batch timer fires.
const remaining = pendingData.get(payload.id)
if (remaining) {
sendPtyDataToRenderer(
payload.id,
makePtyDataPayload(
if (remaining.droppedOutput === true) {
// Sentinel entry: only salvaged query bytes remain; keep the flag so
// the renderer knows the span was dropped (same as the flush loop).
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: remaining.data,
droppedOutput: true
})
} else {
sendPtyDataToRenderer(
payload.id,
remaining.data,
remaining.startSeq,
remaining.containsBackgroundOutput
makePtyDataPayload(
payload.id,
remaining.data,
remaining.startSeq,
remaining.containsBackgroundOutput
)
)
)
}
pendingData.delete(payload.id)
}
// Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY)
@@ -2514,7 +2531,8 @@ export function registerPtyHandlers(
...(typeof pending.startSeq === 'number'
? { seq: pending.startSeq + nextData.length, rawLength: nextData.length }
: {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {})
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
})
return
}
+282
View File
@@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } {
})
}
// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand <b64>`
// command so tests can assert on the actual (UTF-16LE) payload sent to the host.
function decodeEncodedCommand(command: string): string {
const match = /-EncodedCommand (\S+)/.exec(command)
if (!match) {
throw new Error(`no -EncodedCommand in: ${command}`)
}
return Buffer.from(match[1], 'base64').toString('utf16le')
}
describe('registerSshBrowseHandler', () => {
let handler: BrowseHandler
@@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => {
expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap")
})
it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' })
await Promise.resolve()
posixChannel.stderr.emit(
'data',
Buffer.from('"exec" no se reconoce como un comando interno o externo')
)
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
// Windows OpenSSH exec emits CRLF; the parser must strip \r so directories
// aren't misclassified as files with a stray carriage return in the name.
// The script emits a forward-slash resolvedPath (the -replace '\\','/' line)
// so the renderer's parentPath/joinPath, which only split on `/`, still work.
windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n'))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users/alice',
entries: [
{ name: 'Desktop', isDirectory: true },
{ name: 'notes.txt', isDirectory: false }
]
})
expect(exec).toHaveBeenCalledTimes(2)
expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap")
expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /)
expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false })
// Decode the -EncodedCommand payload so an accidental switch from the
// single-quote-escaped PowerShell literal to raw interpolation (an injection
// regression) is caught, and to lock in the UTF-8 output pin.
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8')
expect(script).toContain("$dir = 'C:/Users/alice'")
expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force')
// resolvedPath must be emitted with forward slashes so the renderer's
// parentPath/joinPath (which only split on `/`) keep working on Windows.
expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')")
})
it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => {
// Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with
// localized stderr. The old 9009/English-string trigger silently missed this,
// so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit
// of a RemoteBrowseError fixes it regardless of OS display language.
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' })
await Promise.resolve()
// Japanese cmd.exe "not recognized" text — matches none of the removed English
// /Spanish substrings, and exit 1 is not the removed 9009 sentinel.
posixChannel.stderr.emit(
'data',
Buffer.from("'exec' は、内部コマンドとして認識されていません。")
)
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n'))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: 'C:/Users',
entries: [{ name: 'Admin', isDirectory: true }]
})
expect(exec).toHaveBeenCalledTimes(2)
})
it('escapes single quotes in the PowerShell literal path', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" })
await Promise.resolve()
// A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the
// process boundary) with localized German stderr. The fallback must still fire,
// since it keys off the non-zero exit, not the (localized, unmatchable) text.
posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n"))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({
resolvedPath: "C:/O'Brien",
entries: []
})
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
// Single quote must be doubled inside the PowerShell literal, not passed raw.
expect(script).toContain("$dir = 'C:/O''Brien'")
})
it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' })
await Promise.resolve()
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n'))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] })
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
// ~ must expand to $HOME, not be passed literally to Set-Location.
expect(script).toContain('$dir = $HOME')
})
// The renderer rebuilds forward-slash Windows paths with POSIX helpers: the
// breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a
// first-level dir yields a bare drive letter. Both must be rooted for
// Set-Location, or navigation lands in the drive-relative cwd / errors.
it.each([
{ dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" },
{ dirPath: 'C:', expected: "$dir = 'C:/'" },
// Combined strip + root, so a future refactor can't break the ordering.
{ dirPath: '/C:', expected: "$dir = 'C:/'" }
])(
'roots the Windows drive path $dirPath in the PowerShell fallback',
async ({ dirPath, expected }) => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath })
await Promise.resolve()
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.emit('data', Buffer.from('C:/Users\r\n'))
windowsChannel.emit('exit', 0)
windowsChannel.emit('close')
await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] })
const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '')
expect(script).toContain(expected)
}
)
it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' })
await Promise.resolve()
// A genuine POSIX permission failure exits non-zero; it's indistinguishable
// from a Windows shell reject without probing, so the fallback is attempted...
posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
// ...but on a POSIX host the login shell can't find powershell.exe (exit 127),
// so the original permission error is surfaced, never masked by the retry.
windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found'))
windowsChannel.emit('exit', 127)
windowsChannel.emit('close')
await expect(resultPromise).rejects.toThrow('Permission denied')
expect(exec).toHaveBeenCalledTimes(2)
})
it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' })
await Promise.resolve()
posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized'))
posixChannel.emit('exit', 1)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing'))
windowsChannel.emit('exit', 1)
windowsChannel.emit('close')
// PowerShell exited non-127, so it genuinely ran on a Windows host — its error
// is the real cause. Surface it rather than the cmd.exe "not recognized" prose.
await expect(resultPromise).rejects.toThrow('Cannot find path')
})
it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => {
const posixChannel = createMockChannel()
const windowsChannel = createMockChannel()
const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel)
const getConnectionManager = () => ({
getConnection: () => ({ exec })
})
registerSshBrowseHandler(getConnectionManager as never)
const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' })
await Promise.resolve()
// A non-zero POSIX failure triggers the fallback probe on any host...
posixChannel.stderr.emit('data', Buffer.from('exec: command not found'))
posixChannel.emit('exit', 127)
posixChannel.emit('close')
await vi.waitFor(() => {
expect(windowsChannel.listenerCount('close')).toBe(1)
})
windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found'))
windowsChannel.emit('exit', 127)
windowsChannel.emit('close')
// The original POSIX failure is the real one — don't mask it with the
// misleading "powershell.exe: not found" from the doomed retry.
await expect(resultPromise).rejects.toThrow('exec: command not found')
})
it('rejects and detaches listeners when the browse channel errors', async () => {
const channel = createMockChannel()
const exec = vi.fn().mockResolvedValue(channel)
@@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => {
channel.emit('error', new Error('remote disconnected'))
await expect(resultPromise).rejects.toThrow('remote disconnected')
// A transport failure is not a RemoteBrowseError, so it must not trigger a
// pointless PowerShell retry.
expect(exec).toHaveBeenCalledTimes(1)
expect(channel.listenerCount('data')).toBe(0)
expect(channel.listenerCount('exit')).toBe(0)
expect(channel.listenerCount('close')).toBe(0)
+267 -144
View File
@@ -1,5 +1,7 @@
import { ipcMain } from 'electron'
import type { SshConnectionManager } from '../ssh/ssh-connection'
import type { SshExecOptions } from '../ssh/ssh-connection-utils'
import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
export type RemoteDirEntry = {
name: string
@@ -8,6 +10,33 @@ export type RemoteDirEntry = {
const SSH_BROWSE_TIMEOUT_MS = 15_000
// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX
// "command not found" convention, identical across sh/bash/zsh and locales). It's
// the locale-independent signal that the Windows fallback never actually ran, so
// the original POSIX failure — not the doomed retry — is the real error.
//
// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value
// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit
// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2
// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec`
// wrapper is detected by "the remote command ran and exited non-zero"
// (RemoteBrowseError), not by a magic exit code or localized stderr text.
const POSIX_COMMAND_NOT_FOUND_EXIT = 127
// Carries the raw exit code so the fallback can (a) recognize that the remote
// command actually ran and failed — the locale-independent trigger for the
// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart
// from a genuine PowerShell error, without parsing localized shell prose.
class RemoteBrowseError extends Error {
constructor(
message: string,
readonly exitCode: number | null
) {
super(message)
this.name = 'RemoteBrowseError'
}
}
// Why: the relay's fs.readDir enforces workspace root ACLs, which aren't
// registered until a repo is added. This handler uses a raw SSH exec channel
// to list directories, allowing the user to browse the remote filesystem
@@ -32,156 +61,229 @@ export function registerSshBrowseHandler(
throw new Error(`SSH connection "${args.targetId}" not found`)
}
// Why: using one line per entry preserves filenames containing spaces.
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
// code rather than being indistinguishable from an empty directory.
const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap`
const channel = await conn.exec(command)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let exitCode: number | null = null
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
channel.off('data', onStdoutData)
channel.stderr.off('data', onStderrData)
channel.off('exit', onExit)
channel.off('close', onClose)
channel.off('error', onError)
channel.stderr.off('error', onError)
try {
return await browseWithPosixShell(conn, args.dirPath)
} catch (posixError) {
// Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX
// `exec` wrapper, and the only locale-independent signal for that is "the
// remote command executed and exited non-zero" (RemoteBrowseError). Its
// stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches
// us (sshd forwards process exit 1). Transport errors/timeouts aren't
// RemoteBrowseErrors, so a dropped connection is never retried as Windows.
if (!(posixError instanceof RemoteBrowseError)) {
throw posixError
}
const rejectOnce = (error: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
try {
return await browseWithWindowsPowerShell(conn, args.dirPath)
} catch (fallbackError) {
// Why: if the login shell couldn't find powershell.exe (exit 127) the
// host isn't Windows — surface the original POSIX failure rather than a
// misleading "powershell.exe: not found". Otherwise PowerShell genuinely
// ran and its error (e.g. "Cannot find path") is the real cause.
throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError
}
const closeChannel = (): void => {
const closable = channel as { close?: () => void; destroy?: () => void }
try {
if (typeof closable.close === 'function') {
closable.close()
} else if (typeof closable.destroy === 'function') {
closable.destroy()
}
} catch {
/* best effort */
}
}
const onTimeout = (): void => {
// Why: remote browsing runs before a relay workspace root exists, so
// it cannot rely on relay request deadlines. Bound this raw exec
// channel directly to keep Add Remote Project from hanging forever.
rejectOnce(new Error('Remote directory listing timed out'))
closeChannel()
}
const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve(result)
}
const onStdoutData = (data: Buffer): void => {
stdout += data.toString()
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString()
}
// `exit` fires before `close`; capture the code so we can distinguish
// a failed `ls` that still produced `pwd` output from an empty listing.
const onExit = (code: number | null): void => {
exitCode = code
}
const onError = (error: Error): void => {
rejectOnce(error)
}
const onClose = (): void => {
// A null exitCode means the server closed the channel without
// sending an exit-status message (or signalled termination). We
// can't assume success — falling back to "empty stdout = empty
// directory" is exactly the bug the exit-code branch was added to
// fix. Treat any non-zero OR null exit as a failure when stderr
// has content, and otherwise require stdout to contain at least
// the resolved `pwd` line before accepting the result.
if (exitCode !== 0) {
const msg =
stderr.trim() ||
(exitCode === null
? 'Remote listing failed (channel closed without exit status)'
: `Remote listing failed (exit ${exitCode})`)
rejectOnce(new Error(msg))
return
}
if (stderr.trim() && !stdout.trim()) {
rejectOnce(new Error(stderr.trim()))
return
}
const lines = stdout.trim().split('\n')
if (lines.length === 0) {
rejectOnce(new Error('Empty response from remote'))
return
}
const resolvedPath = lines[0]
const entries: RemoteDirEntry[] = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
if (!line || line === './' || line === '../') {
continue
}
if (line.endsWith('/')) {
entries.push({ name: line.slice(0, -1), isDirectory: true })
} else {
entries.push({ name: line, isDirectory: false })
}
}
// Sort: directories first, then alphabetical
entries.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
resolveOnce({ entries, resolvedPath })
}
channel.on('data', onStdoutData)
channel.stderr.on('data', onStderrData)
channel.on('exit', onExit)
channel.on('close', onClose)
// Why: SSH exec streams emit `error` on transport loss; without a
// scoped listener, a disappearing remote can become process-fatal.
channel.on('error', onError)
channel.stderr.on('error', onError)
timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
}
}
)
}
type SshBrowseConnection = NonNullable<ReturnType<SshConnectionManager['getConnection']>>
function browseWithPosixShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
// Why: using one line per entry preserves filenames containing spaces.
// `command ls` bypasses user aliases/functions like `ls='eza ...'`.
// The -1 flag outputs one entry per line. The -p flag appends / to directories.
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
// `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission
// denied after a readable `cd ... && pwd`) propagates as a non-zero exit
// code rather than being indistinguishable from an empty directory.
return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`)
}
function browseWithWindowsPowerShell(
conn: SshBrowseConnection,
dirPath: string
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
const script = [
"$ErrorActionPreference = 'Stop'",
// Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM
// code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so
// non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake.
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
`$dir = ${powerShellPathExpression(dirPath)}`,
'Set-Location -LiteralPath $dir',
'$resolved = (Get-Location).ProviderPath',
// Why: the renderer's parentPath/joinPath only split on `/`, so a native
// backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a
// forward-slash resolvedPath (matching the POSIX branch) while keeping the
// native $resolved for Get-ChildItem -LiteralPath.
"Write-Output ($resolved -replace '\\\\', '/')",
'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {',
" if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }",
'}'
].join('; ')
return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false })
}
async function runBrowseCommand(
conn: SshBrowseConnection,
command: string,
options?: SshExecOptions
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> {
const channel = options ? await conn.exec(command, options) : await conn.exec(command)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
let exitCode: number | null = null
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
channel.off('data', onStdoutData)
channel.stderr.off('data', onStderrData)
channel.off('exit', onExit)
channel.off('close', onClose)
channel.off('error', onError)
channel.stderr.off('error', onError)
}
const rejectOnce = (error: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
}
const closeChannel = (): void => {
const closable = channel as { close?: () => void; destroy?: () => void }
try {
if (typeof closable.close === 'function') {
closable.close()
} else if (typeof closable.destroy === 'function') {
closable.destroy()
}
} catch {
/* best effort */
}
}
const onTimeout = (): void => {
// Why: remote browsing runs before a relay workspace root exists, so
// it cannot rely on relay request deadlines. Bound this raw exec
// channel directly to keep Add Remote Project from hanging forever.
rejectOnce(new Error('Remote directory listing timed out'))
closeChannel()
}
const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve(result)
}
const onStdoutData = (data: Buffer): void => {
stdout += data.toString()
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString()
}
// `exit` fires before `close`; capture the code so we can distinguish
// a failed `ls` that still produced `pwd` output from an empty listing.
const onExit = (code: number | null): void => {
exitCode = code
}
const onError = (error: Error): void => {
rejectOnce(error)
}
const onClose = (): void => {
// A null exitCode means the server closed the channel without
// sending an exit-status message (or signalled termination). We
// can't assume success — falling back to "empty stdout = empty
// directory" is exactly the bug the exit-code branch was added to
// fix. Treat any non-zero OR null exit as a failure when stderr
// has content, and otherwise require stdout to contain at least
// the resolved `pwd` line before accepting the result.
if (exitCode !== 0) {
const msg =
stderr.trim() ||
(exitCode === null
? 'Remote listing failed (channel closed without exit status)'
: `Remote listing failed (exit ${exitCode})`)
rejectOnce(new RemoteBrowseError(msg, exitCode))
return
}
if (stderr.trim() && !stdout.trim()) {
rejectOnce(new Error(stderr.trim()))
return
}
// Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a
// trailing \r defeats the endsWith('/') dir check and leaves a stray CR
// in every name.
const lines = stdout.trim().split(/\r?\n/)
if (lines.length === 0) {
rejectOnce(new Error('Empty response from remote'))
return
}
const resolvedPath = lines[0]
const entries: RemoteDirEntry[] = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
if (!line || line === './' || line === '../') {
continue
}
if (line.endsWith('/')) {
entries.push({ name: line.slice(0, -1), isDirectory: true })
} else {
entries.push({ name: line, isDirectory: false })
}
}
// Sort: directories first, then alphabetical
entries.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
})
resolveOnce({ entries, resolvedPath })
}
channel.on('data', onStdoutData)
channel.stderr.on('data', onStderrData)
channel.on('exit', onExit)
channel.on('close', onClose)
// Why: SSH exec streams emit `error` on transport loss; without a
// scoped listener, a disappearing remote can become process-fatal.
channel.on('error', onError)
channel.stderr.on('error', onError)
timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
}
// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the
// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX
// failure, not the doomed retry, is the error worth surfacing.
function isPosixCommandNotFound(error: unknown): boolean {
return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT
}
// Why: prevent shell injection in the directory path. Single-quote wrapping
// with escaped internal single quotes is the safest approach for sh/bash.
// Tilde must be expanded by the shell, so paths starting with ~ use $HOME
@@ -199,3 +301,24 @@ function shellEscape(s: string): string {
function shellEscapeRaw(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
function powerShellPathExpression(s: string): string {
if (s === '~') {
return '$HOME'
}
if (s.startsWith('~/') || s.startsWith('~\\')) {
return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}`
}
return powerShellLiteral(normalizeWindowsDrivePath(s))
}
// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them
// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the
// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter
// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's
// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both
// back to a rooted drive path here so navigation lands where the user clicked.
function normalizeWindowsDrivePath(s: string): string {
const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '')
return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped
}
+2 -2
View File
@@ -714,8 +714,8 @@ export function registerSshHandlers(
await removeRegisteredSshTarget(args.id)
})
ipcMain.handle('ssh:importConfig', () => {
return sshStore!.importFromSshConfig()
ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => {
return sshStore!.importFromSshConfig(args)
})
// ── Connection lifecycle ───────────────────────────────────────────
+4
View File
@@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
}
import { classifyWorkspaceCreateError } from './workspace-create-error-classifier'
import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy'
import {
assertWorktreeDoesNotContainRegisteredWorktree,
canCleanupUnregisteredOrcaLeftoverDirectory,
@@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin
// drop process-local caches before the same ID can point at a new workspace.
store.removeWorktreeMeta(worktreeId)
advertisedUrlWatcher.forgetWorktree(worktreeId)
// Why: drop this worktree's localhost label routes so they don't accumulate
// in the proxy's route maps for the rest of the session.
localhostWorktreeLabelProxy.unregisterWorktree(worktreeId)
deleteWorktreeHistoryDir(worktreeId)
// Why: release the removed worktree's PR-refresh aliases so coalesced queue
// entries do not retain it for the rest of the session (memory creep).
@@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => {
expect(result.status).toBe(404)
})
it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => {
const port = await startUpstream((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' })
response.end('ok')
})
const proxy = new LocalhostWorktreeLabelProxy()
const a = await proxy.registerRoute({
targetUrl: `http://localhost:${port}/`,
projectName: 'Snap Studio',
worktreeName: 'feature-a',
worktreeId: 'wt-a'
})
const b = await proxy.registerRoute({
targetUrl: `http://localhost:${port}/`,
projectName: 'Snap Studio',
worktreeName: 'feature-b',
worktreeId: 'wt-b'
})
expect((await fetchThroughProxy(a.url)).status).toBe(200)
expect((await fetchThroughProxy(b.url)).status).toBe(200)
proxy.unregisterWorktree('wt-a')
// The removed worktree's label no longer routes; the other worktree is unaffected.
expect((await fetchThroughProxy(a.url)).status).toBe(404)
expect((await fetchThroughProxy(b.url)).status).toBe(200)
})
})
@@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy {
}
}
// Why: routes are added per (worktreeId, targetUrl) but were never removed, so
// labels for deleted worktrees accumulated in both maps for the whole session.
// Drop them when the worktree is torn down. The shared http.Server stays up.
unregisterWorktree(worktreeId: string): void {
for (const [label, route] of this.routes) {
if (route.worktreeId === worktreeId) {
this.routes.delete(label)
this.routeKeys.delete(route.routeKey)
}
}
}
private async ensureServer(): Promise<void> {
if (this.server && this.listenPort !== null) {
return
@@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => {
import { isTextBlock } from '../../shared/native-chat-types'
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached
readNativeChatTranscriptCached,
setNativeChatTranscriptCacheMaxBytesForTests
} from './transcript-read-cache'
let tempRoots: string[] = []
@@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise<string> {
return filePath
}
// Writes a transcript file at an explicit path whose on-disk size is ~`bytes`
// (padded via one big user message), returning the path. Read it back by passing
// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME.
async function seedBigFile(name: string, bytes: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-'))
tempRoots.push(root)
const filePath = join(root, `${name}.jsonl`)
const record = {
type: 'user',
uuid: `u-${name}`,
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) }
}
await writeFile(filePath, jsonLines([record]))
return filePath
}
beforeEach(() => {
clearNativeChatTranscriptCache()
setNativeChatTranscriptCacheMaxBytesForTests()
readSpy.mockClear()
})
@@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => {
// Distinct files must not share a cached parse object.
expect(c).not.toBe(a)
})
// Why: each cached entry is a FULL unwindowed transcript parse; heavy agent
// sessions are tens of MB, so the count-only cap let 50 entries retain multiple
// GB in the one process serving desktop + every paired client. The byte budget
// evicts the oldest large entries while always keeping the most-recent.
it('evicts the oldest entry once total cached bytes exceed the budget', async () => {
// ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest.
setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024)
const fileA = await seedBigFile('big-a', 4 * 1024)
const fileB = await seedBigFile('big-b', 4 * 1024)
const fileC = await seedBigFile('big-c', 4 * 1024)
await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1
await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2
await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A
expect(readSpy).toHaveBeenCalledTimes(3)
// C is the most-recent → still cached (no re-read).
await readNativeChatTranscriptCached('claude', 'sc', fileC)
expect(readSpy).toHaveBeenCalledTimes(3)
// A was evicted by the byte cap → this re-reads (and evicts B in turn).
await readNativeChatTranscriptCached('claude', 'sa', fileA)
expect(readSpy).toHaveBeenCalledTimes(4)
// B is now evicted → also re-reads.
await readNativeChatTranscriptCached('claude', 'sb', fileB)
expect(readSpy).toHaveBeenCalledTimes(5)
})
it('keeps a single active transcript larger than the whole budget cached', async () => {
// A lone entry over budget must NOT be dropped, else every read re-parses.
setNativeChatTranscriptCacheMaxBytesForTests(1024)
const big = await seedBigFile('huge', 8 * 1024)
await readNativeChatTranscriptCached('claude', 'huge', big)
await readNativeChatTranscriptCached('claude', 'huge', big)
expect(readSpy).toHaveBeenCalledTimes(1)
})
it('does not evict small entries under the default budget (no regression for typical use)', async () => {
const files = await Promise.all([
seedBigFile('s1', 512),
seedBigFile('s2', 512),
seedBigFile('s3', 512)
])
for (const [i, file] of files.entries()) {
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
}
expect(readSpy).toHaveBeenCalledTimes(3)
// Re-read all three: every one is still cached (byte budget never triggered).
for (const [i, file] of files.entries()) {
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
}
expect(readSpy).toHaveBeenCalledTimes(3)
})
})
+34 -8
View File
@@ -21,6 +21,9 @@ type CachedTranscript = {
result: ReadTranscriptResult
/** mtime of the resolved file when cached; a newer mtime invalidates it. */
mtimeMs: number
/** On-disk byte size of the resolved file — a cheap, monotonic proxy for this
* entry's parsed memory footprint, used to bound the cache by total bytes. */
bytes: number
}
const cache = new Map<string, CachedTranscript>()
@@ -28,19 +31,36 @@ const cache = new Map<string, CachedTranscript>()
// Why: cap the cache so a long-lived process browsing many sessions can't grow
// it unbounded. Map preserves insertion order, so evicting the first key drops
// the oldest entry (a simple LRU once re-inserts bump recency; see setCached).
// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling
// shows RSS pressure now that one process serves many remote clients.
const MAX_CACHE_ENTRIES = 50
// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool
// results embed whole file contents, command output, and diffs), and each cached
// entry is the full unwindowed parse. The count cap alone let 50 such entries
// retain multiple GB in the one process that now serves desktop + every paired
// web/mobile client. Bound total cached file bytes too; we always keep the most-
// recent entry (see setCached) so an active transcript is never re-parsed on
// every read, which caps the regression to extra re-parses only past this budget.
const MAX_CACHE_BYTES = 128 * 1024 * 1024
// Overridable only from tests so the byte-eviction path can be exercised without
// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES.
let maxCacheBytes = MAX_CACHE_BYTES
function setCached(key: string, value: CachedTranscript): void {
// Re-insert moves the key to the most-recent position for LRU eviction.
cache.delete(key)
cache.set(key, value)
while (cache.size > MAX_CACHE_ENTRIES) {
let totalBytes = 0
for (const entry of cache.values()) {
totalBytes += entry.bytes
}
// Evict oldest until within BOTH caps, but never drop the most-recent entry
// (cache.size > 1): a single active transcript larger than the whole budget
// must stay cached or every read would re-parse the full file.
while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
totalBytes -= cache.get(oldest)?.bytes ?? 0
cache.delete(oldest)
}
}
@@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string {
return `${agent}:${filePath}`
}
async function fileMtimeMs(filePath: string): Promise<number> {
async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> {
try {
return (await stat(filePath)).mtimeMs
const stats = await stat(filePath)
return { mtimeMs: stats.mtimeMs, bytes: stats.size }
} catch {
return Number.NaN
return { mtimeMs: Number.NaN, bytes: 0 }
}
}
@@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached(
}
const key = cacheKey(agent, filePath)
const mtimeMs = await fileMtimeMs(filePath)
const { mtimeMs, bytes } = await fileStat(filePath)
const cached = cache.get(key)
if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) {
// Bump recency so a frequently-read session survives eviction.
@@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached(
const result = await readNativeChatTranscript(agent, sessionId, { filePath })
if (Number.isFinite(mtimeMs)) {
setCached(key, { result, mtimeMs })
setCached(key, { result, mtimeMs, bytes })
}
return result
}
@@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached(
export function clearNativeChatTranscriptCache(): void {
cache.clear()
}
/** Test-only: override the byte budget (pass no arg to restore the default). */
export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void {
maxCacheBytes = bytes ?? MAX_CACHE_BYTES
}
+33
View File
@@ -3339,6 +3339,11 @@ export class Store {
defaults.workspaceSession
),
sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget),
deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases)
? parsed.deletedSshConfigAliases.filter(
(alias): alias is string => typeof alias === 'string'
)
: [],
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
.map(normalizeSshRemotePtyLease)
.filter((lease): lease is SshRemotePtyLease => lease !== null),
@@ -5973,6 +5978,34 @@ export class Store {
this.scheduleSave()
}
getDeletedSshConfigAliases(): string[] {
return [...(this.state.deletedSshConfigAliases ?? [])]
}
addDeletedSshConfigAlias(alias: string): void {
this.state.deletedSshConfigAliases ??= []
if (!this.state.deletedSshConfigAliases.includes(alias)) {
this.state.deletedSshConfigAliases.push(alias)
this.scheduleSave()
}
}
removeDeletedSshConfigAlias(alias: string): void {
const current = this.state.deletedSshConfigAliases
if (!current || !current.includes(alias)) {
return
}
this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias)
this.scheduleSave()
}
clearDeletedSshConfigAliases(): void {
if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) {
this.state.deletedSshConfigAliases = []
this.scheduleSave()
}
}
// ── SSH Remote PTY Leases ──────────────────────────────────────────
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
@@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => {
const result = await provider.readFile('/home/user/file.txt')
expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', {
filePath: '/home/user/file.txt'
filePath: '/home/user/file.txt',
flowControl: 'ack'
})
expect(result).toEqual({ content: text, isBinary: false })
})
+1
View File
@@ -153,6 +153,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'files.openDiff',
'files.read',
'files.readChunk',
'files.readDir',
'files.readPreview',
'files.readTerminalArtifact',
'files.readTerminalArtifactPreview',
+69 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
HEADER_LENGTH,
MessageType,
@@ -160,6 +160,74 @@ describe('FrameDecoder', () => {
expect(frames).toHaveLength(1)
expect(frames[0].id).toBe(2)
})
it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => {
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder((f) => frames.push(f))
const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload'))
const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second'))
const combined = Buffer.concat([frame1, frame2])
for (let i = 0; i < combined.length; i += 1) {
decoder.feed(combined.subarray(i, i + 1))
}
expect(frames).toHaveLength(2)
expect(frames[0].payload.toString()).toBe('first payload')
expect(frames[1].id).toBe(2)
expect(frames[1].payload.toString()).toBe('second')
})
it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => {
const errors: Error[] = []
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder(
(f) => frames.push(f),
(err) => errors.push(err)
)
const oversizedLength = 17 * 1024 * 1024
const header = Buffer.alloc(HEADER_LENGTH)
header[0] = MessageType.Regular
header.writeUInt32BE(1, 1)
header.writeUInt32BE(0, 5)
header.writeUInt32BE(oversizedLength, 9)
const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)])
const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after'))
const combined = Buffer.concat([oversized, valid])
const chunkSize = 1024 * 1024 - 7
for (let i = 0; i < combined.length; i += chunkSize) {
decoder.feed(combined.subarray(i, i + chunkSize))
}
expect(errors).toHaveLength(1)
expect(frames).toHaveLength(1)
expect(frames[0].payload.toString()).toBe('after')
})
it('never rebuilds the buffered stream per feed while assembling a large frame', () => {
// Regression: feed() used Buffer.concat([buffered, chunk]) per data event,
// re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the
// Electron main thread while fs.streamChunk frames arrive (SSH typing lag).
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder((f) => frames.push(f))
const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61))
const concatSpy = vi.spyOn(Buffer, 'concat')
try {
for (let i = 0; i < frame.length; i += 32 * 1024) {
decoder.feed(frame.subarray(i, i + 32 * 1024))
}
} finally {
concatSpy.mockRestore()
}
expect(frames).toHaveLength(1)
expect(frames[0].payload.length).toBe(512 * 1024)
expect(concatSpy).not.toHaveBeenCalled()
})
})
describe('parseJsonRpcMessage', () => {
+94 -19
View File
@@ -127,7 +127,13 @@ export type DecodedFrame = {
* Incremental frame parser. Feed it chunks of data; it emits complete frames.
*/
export class FrameDecoder {
private buffer = Buffer.alloc(0)
// Why: feed() runs on the Electron main thread for every SSH channel data
// event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies
// every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per
// large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk
// list assembles each frame exactly once instead.
private chunks: Buffer[] = []
private bufferedLength = 0
private onFrame: (frame: DecodedFrame) => void
private onError: ((err: Error) => void) | null
@@ -137,21 +143,31 @@ export class FrameDecoder {
}
feed(chunk: Buffer | Uint8Array): void {
this.buffer = Buffer.concat([this.buffer, chunk])
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
if (buf.length > 0) {
this.chunks.push(buf)
this.bufferedLength += buf.length
}
while (this.buffer.length >= HEADER_LENGTH) {
const length = this.buffer.readUInt32BE(9)
while (this.bufferedLength >= HEADER_LENGTH) {
const header = this.peekBytes(HEADER_LENGTH)
const length = header.readUInt32BE(9)
const totalLength = HEADER_LENGTH + length
if (this.bufferedLength < totalLength) {
// Not fully received yet (also holds oversized frames until they can
// be skipped whole, keeping the decoder synchronized).
break
}
// Why: throwing here would leave the buffer in a partially consumed
// state — subsequent feed() calls would try to parse leftover payload
// bytes as a new header, corrupting every future frame. Instead we
// skip the entire oversized frame so the decoder stays synchronized.
if (length > MAX_MESSAGE_SIZE) {
if (this.buffer.length < totalLength) {
break
}
this.buffer = this.buffer.subarray(totalLength)
this.discardBytes(totalLength)
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
if (this.onError) {
this.onError(err)
@@ -159,24 +175,83 @@ export class FrameDecoder {
continue
}
if (this.buffer.length < totalLength) {
break
}
const framed = this.takeBytes(totalLength)
const frame: DecodedFrame = {
type: this.buffer[0],
id: this.buffer.readUInt32BE(1),
ack: this.buffer.readUInt32BE(5),
payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
type: framed[0],
id: framed.readUInt32BE(1),
ack: framed.readUInt32BE(5),
payload: framed.subarray(HEADER_LENGTH, totalLength)
}
this.buffer = this.buffer.subarray(totalLength)
this.onFrame(frame)
}
}
reset(): void {
this.buffer = Buffer.alloc(0)
this.chunks = []
this.bufferedLength = 0
}
/** View of the first `count` buffered bytes without consuming them. */
private peekBytes(count: number): Buffer {
const first = this.chunks[0]
if (first.length >= count) {
return first
}
const out = Buffer.allocUnsafe(count)
let copied = 0
for (const part of this.chunks) {
copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
if (copied >= count) {
break
}
}
return out
}
/** Consume and return the first `count` buffered bytes (single copy). */
private takeBytes(count: number): Buffer {
const first = this.chunks[0]
if (first.length === count) {
this.chunks.shift()
this.bufferedLength -= count
return first
}
if (first.length > count) {
this.chunks[0] = first.subarray(count)
this.bufferedLength -= count
return first.subarray(0, count)
}
const out = Buffer.allocUnsafe(count)
let copied = 0
while (copied < count) {
const part = this.chunks[0]
const take = Math.min(part.length, count - copied)
part.copy(out, copied, 0, take)
copied += take
if (take === part.length) {
this.chunks.shift()
} else {
this.chunks[0] = part.subarray(take)
}
}
this.bufferedLength -= count
return out
}
/** Consume the first `count` buffered bytes without assembling them. */
private discardBytes(count: number): void {
let remaining = count
while (remaining > 0) {
const part = this.chunks[0]
if (part.length <= remaining) {
this.chunks.shift()
remaining -= part.length
} else {
this.chunks[0] = part.subarray(remaining)
remaining = 0
}
}
this.bufferedLength -= count
}
}
+115
View File
@@ -14,6 +14,7 @@ vi.mock('./ssh-config-parser', () => ({
function createMockStore() {
const targets: SshTarget[] = []
let deletedAliases: string[] = []
return {
getSshTargets: vi.fn(() => [...targets]),
@@ -32,6 +33,18 @@ function createMockStore() {
if (idx !== -1) {
targets.splice(idx, 1)
}
}),
getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]),
addDeletedSshConfigAlias: vi.fn((alias: string) => {
if (!deletedAliases.includes(alias)) {
deletedAliases.push(alias)
}
}),
removeDeletedSshConfigAlias: vi.fn((alias: string) => {
deletedAliases = deletedAliases.filter((entry) => entry !== alias)
}),
clearDeletedSshConfigAliases: vi.fn(() => {
deletedAliases = []
})
}
}
@@ -324,4 +337,106 @@ describe('SshConnectionStore', () => {
expect(result).toEqual([])
})
})
describe('deleted config host tombstones', () => {
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
return {
id: `tmp-${overrides.configHost}`,
label: overrides.configHost,
host: `${overrides.configHost}.example.com`,
port: 22,
username: '',
...overrides
}
}
// PRIMARY regression: deleting a config-sourced host must not let the next
// ~/.ssh/config sync resurrect it.
it('tombstones a deleted config-sourced host so re-import skips it', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert
expect(result).toEqual([])
})
it('does not tombstone a manual target on delete', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'manual'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled()
})
it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => {
mockStore.addDeletedSshConfigAlias('mini')
sshStore.addTarget({
label: 'mini',
configHost: 'mini',
host: '10.0.0.2',
port: 22,
username: 'ping'
})
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
// Alias reclaimed, but it is now a manual target — still not re-inserted.
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
})
it('editing a target reclaims its alias from the deleted set', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
mockStore.addDeletedSshConfigAlias('mini')
sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' })
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
})
it('reAdopt clears all tombstones and re-imports the deleted host', () => {
mockStore.addDeletedSshConfigAlias('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig({ reAdopt: true })
expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled()
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'mini', source: 'ssh-config' })
)
expect(result).toHaveLength(1)
})
})
})
+50 -2
View File
@@ -23,6 +23,9 @@ export class SshConnectionStore {
source: target.source ?? 'manual',
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
// Why: re-adding a host the user previously deleted is an explicit intent to
// keep it — lift any tombstone so config sync stops suppressing this alias.
this.reclaimAlias(full.configHost ?? full.label)
this.store.addSshTarget(full)
return full
}
@@ -51,19 +54,49 @@ export class SshConnectionStore {
}
updateTarget(id: string, updates: Partial<Omit<SshTarget, 'id'>>): SshTarget | null {
return this.store.updateSshTarget(id, updates)
const updated = this.store.updateSshTarget(id, updates)
if (updated) {
// Why: actively editing a target reclaims its alias from the deleted set,
// so an edit can never leave the host tombstoned.
this.reclaimAlias(updated.configHost ?? updated.label)
}
return updated
}
removeTarget(id: string): void {
const target = this.store.getSshTarget(id)
// Why: deleting a config-managed target must record a tombstone; otherwise
// the next ~/.ssh/config sync re-inserts it verbatim (the config entry still
// exists on disk) and the host reappears. Manual targets need no tombstone —
// sync never re-adds them.
if (target && isConfigManagedTarget(target)) {
const alias = target.configHost ?? target.label
if (alias) {
this.store.addDeletedSshConfigAlias(alias)
}
}
this.store.removeSshTarget(id)
}
private reclaimAlias(alias: string | undefined): void {
if (alias) {
this.store.removeDeletedSshConfigAlias(alias)
}
}
/**
* Sync targets from ~/.ssh/config: insert new hosts, update existing
* config-sourced ones in place (so a rotated port takes effect), never touch
* manual targets. Returns the inserted and updated targets.
*/
importFromSshConfig(): SshTarget[] {
importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] {
// Why: the explicit Import action re-adopts every config host, so it clears
// all tombstones first. The passive on-open sync passes no flag and keeps
// deleted hosts suppressed.
if (options?.reAdopt) {
this.store.clearDeletedSshConfigAliases()
}
const deletedAliases = new Set(this.store.getDeletedSshConfigAliases())
const configHosts = loadUserSshConfig()
const existingTargets = this.store.getSshTargets()
// Map config-managed targets (and legacy targets that strongly look like
@@ -102,6 +135,11 @@ export class SshConnectionStore {
// A manual target owns this alias — never clobber it.
continue
}
if (deletedAliases.has(alias)) {
// The user deleted this config host — stay deleted until they re-add it
// or re-adopt config explicitly.
continue
}
if (processedAliases.has(alias)) {
continue
}
@@ -156,6 +194,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean {
return target.owner?.type === 'on-demand-runtime'
}
function isConfigManagedTarget(target: SshTarget): boolean {
// Why: a target is subject to config sync (and therefore needs a tombstone on
// delete) when it is explicitly config-sourced, or a legacy import that sync
// still adopts. Manual targets are excluded — sync never re-adds them.
return (
target.source === 'ssh-config' ||
(target.source === undefined && isLegacyConfigImportTarget(target))
)
}
function isLegacyConfigImportTarget(target: SshTarget): boolean {
const alias = target.configHost ?? target.label
// Why: legacy manual and imported targets both lack `source`. Only adopt the
+7 -1
View File
@@ -148,6 +148,10 @@ export async function readFileViaStream(
expectedSeq += 1
receivedChunks += 1
bytesReceived += decoded.length
// Why: credit-based flow control — the relay caps unacked chunks so bulk
// stream frames cannot queue unbounded ahead of interactive pty.data
// frames on the shared SSH channel. Old relays ignore this notification.
mux.notify('fs.streamAck', { streamId: id, seq })
}
const handleEnd = (params: Record<string, unknown>): void => {
@@ -260,7 +264,9 @@ export async function readFileViaStream(
unsubscribers.push(onDispose)
void mux
.request('fs.readFileStream', { filePath })
// Why: flowControl declares this client acks each chunk, letting a new
// relay pace the pump. Old relays ignore the extra param and flood.
.request('fs.readFileStream', { filePath, flowControl: 'ack' })
.then((rawMetadata) => {
if (settled) {
return
+21
View File
@@ -267,6 +267,27 @@ describe('SshPortForwardManager', () => {
)
})
it('keeps only a bounded tail of a chatty forward stderr (memory-leak regression)', async () => {
const onForwardClosed = vi.fn()
manager.setCallbacks({ onForwardClosed })
const forward = createFakeSystemSshForward()
startSystemSshPortForwardProcessMock.mockReturnValue(forward)
const conn = createSystemSshConn()
await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080)
// A long-lived forward against a chatty remote sshd: emit >64 KB of stderr.
forward.process.stderr.emit('data', Buffer.from(`HEAD_MARKER${'x'.repeat(70 * 1024)}`))
forward.process.stderr.emit('data', Buffer.from('TAIL_MARKER'))
forward.process.emit('exit', 255)
const detail = onForwardClosed.mock.calls[0]?.[1]?.detail as string
// The oldest bytes are trimmed; the most-recent tail is retained.
expect(detail).toContain('TAIL_MARKER')
expect(detail).not.toContain('HEAD_MARKER')
// Bounded well below the ~70 KB produced.
expect(detail.length).toBeLessThan(66 * 1024)
})
it('lists forwards filtered by connectionId', async () => {
const conn = createMockConn()
await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080)
@@ -33,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider {
)
await forward.waitForStartup()
// Why: this stderr stays attached for the forward's whole lifetime but is
// only used to build the exit-error detail, so keep a bounded tail — a chatty
// remote sshd could otherwise grow it unbounded on a long-lived forward
// (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers).
const MAX_STDERR_TAIL_BYTES = 64 * 1024
let stderr = ''
const onStderr = (chunk: Buffer): void => {
stderr += chunk.toString('utf-8')
if (stderr.length > MAX_STDERR_TAIL_BYTES) {
stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES)
}
}
forward.process.stderr?.on('data', onStderr)
+54 -3
View File
@@ -9,16 +9,56 @@ vi.mock('child_process', () => ({
execFile: execFileMock
}))
function expectedFallbackFont(): string {
if (process.platform === 'darwin') {
function expectedFallbackFont(platform = process.platform): string {
if (platform === 'darwin') {
return 'SF Mono'
}
if (process.platform === 'win32') {
if (platform === 'win32') {
return 'Cascadia Mono'
}
return 'JetBrains Mono'
}
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await fn()
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
}
async function expectFontCommandTimeout(
platform: NodeJS.Platform,
timeoutMs: number
): Promise<void> {
await withPlatform(platform, async () => {
vi.useFakeTimers()
execFileMock.mockReturnValue({ kill: killMock })
const { listSystemFontFamilies } = await import('./system-fonts')
const fontsPromise = listSystemFontFamilies()
let resolvedFonts: string[] | null = null
fontsPromise.then((fonts) => {
resolvedFonts = fonts
})
await vi.advanceTimersByTimeAsync(timeoutMs - 1)
expect(resolvedFonts).toBeNull()
expect(killMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform))
expect(killMock).toHaveBeenCalledOnce()
})
}
describe('listSystemFontFamilies', () => {
afterEach(() => {
vi.useRealTimers()
@@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => {
expect(resolvedFonts).toContain(expectedFallbackFont())
expect(killMock).toHaveBeenCalledOnce()
})
it('uses the longer timeout for macOS profiler scans', async () => {
await expectFontCommandTimeout('darwin', 45_000)
})
it.each([
['linux' as NodeJS.Platform, 15_000],
['win32' as NodeJS.Platform, 15_000]
])('keeps the %s font command timeout short', async (platform, timeoutMs) => {
await expectFontCommandTimeout(platform, timeoutMs)
})
})
+28 -17
View File
@@ -3,6 +3,9 @@ import { execFile } from 'node:child_process'
let cachedFonts: string[] | null = null
let fontsPromise: Promise<string[]> | null = null
const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000
// Why: large macOS font catalogs can make system_profiler exceed 15s even
// when it is healthy; keep the longer wait scoped to that slow command.
const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000
export async function listSystemFontFamilies(): Promise<string[]> {
if (cachedFonts) {
@@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise<string[]> {
}
function listMacFonts(): Promise<string[]> {
return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then(
(output) => {
const parsed = JSON.parse(output) as {
SPFontsDataType?: {
typefaces?: {
family?: string
}[]
return execFileText(
'system_profiler',
['SPFontsDataType', '-json'],
32 * 1024 * 1024,
MAC_SYSTEM_FONT_LIST_TIMEOUT_MS
).then((output) => {
const parsed = JSON.parse(output) as {
SPFontsDataType?: {
typefaces?: {
family?: string
}[]
}
return uniqueSorted(
(parsed.SPFontsDataType ?? []).flatMap((font) =>
(font.typefaces ?? []).map((typeface) => typeface.family)
)
)
}[]
}
)
return uniqueSorted(
(parsed.SPFontsDataType ?? []).flatMap((font) =>
(font.typefaces ?? []).map((typeface) => typeface.family)
)
)
})
}
function listLinuxFonts(): Promise<string[]> {
@@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name }
)
}
function execFileText(command: string, args: string[], maxBuffer: number): Promise<string> {
function execFileText(
command: string,
args: string[],
maxBuffer: number,
timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS
): Promise<string> {
return new Promise((resolve, reject) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | undefined
@@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi
// should fall back instead of keeping settings IPC pending forever.
child.kill()
reject(new Error(`Timed out listing system fonts with ${command}`))
}, SYSTEM_FONT_LIST_TIMEOUT_MS)
}, timeoutMs)
if (typeof timer === 'object' && 'unref' in timer) {
timer.unref()
}
+1 -1
View File
@@ -2879,7 +2879,7 @@ export type PreloadApi = {
updates: Partial<Omit<SshTarget, 'id'>>
}) => Promise<SshTarget>
removeTarget: (args: { id: string }) => Promise<void>
importConfig: () => Promise<SshTarget[]>
importConfig: (args?: { reAdopt?: boolean }) => Promise<SshTarget[]>
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
disconnect: (args: { targetId: string }) => Promise<void>
terminateSessions: (args: { targetId: string }) => Promise<void>
+2 -1
View File
@@ -3947,7 +3947,8 @@ const api = {
removeTarget: (args: { id: string }): Promise<void> =>
ipcRenderer.invoke('ssh:removeTarget', args),
importConfig: (): Promise<SshTarget[]> => ipcRenderer.invoke('ssh:importConfig'),
importConfig: (args?: { reAdopt?: boolean }): Promise<SshTarget[]> =>
ipcRenderer.invoke('ssh:importConfig', args),
connect: (args: { targetId: string }): Promise<SshConnectionState | null> =>
ipcRenderer.invoke('ssh:connect', args),
+115
View File
@@ -395,4 +395,119 @@ describe('RelayDispatcher', () => {
expect(listener).toHaveBeenCalledWith(1)
})
describe('notifyBulk (bulk lane backpressure)', () => {
it('resolves immediately when the sink accepts the frame', async () => {
const frames: Buffer[] = []
const bulkDispatcher = new RelayDispatcher((data) => {
frames.push(Buffer.from(data))
return true
})
try {
await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
expect(frames).toHaveLength(1)
const frame = decodeFirstFrame(frames[0])
const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification
expect(msg.method).toBe('bulk.event')
} finally {
bulkDispatcher.dispose()
}
})
it('holds the next bulk frame until the saturated sink drains', async () => {
const frames: Buffer[] = []
const drainWaiters = new Set<() => void>()
const bulkDispatcher = new RelayDispatcher(
(data) => {
frames.push(Buffer.from(data))
return false
},
{ waitWriteDrain: (cb) => drainWaiters.add(cb) }
)
try {
let firstSettled = false
const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => {
firstSettled = true
})
void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
await vi.advanceTimersByTimeAsync(0)
// First frame written, but its send has not settled and the second
// frame is not admitted while the sink stays saturated.
expect(frames).toHaveLength(1)
expect(firstSettled).toBe(false)
for (const cb of Array.from(drainWaiters)) {
drainWaiters.delete(cb)
cb()
}
await first
await vi.advanceTimersByTimeAsync(0)
expect(frames).toHaveLength(2)
} finally {
bulkDispatcher.dispose()
}
})
it('interactive notify() frames are not gated behind a stalled bulk lane', async () => {
const frames: Buffer[] = []
const bulkDispatcher = new RelayDispatcher(
(data) => {
frames.push(Buffer.from(data))
return false
},
{ waitWriteDrain: () => {} }
)
try {
void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 })
await vi.advanceTimersByTimeAsync(0)
expect(frames).toHaveLength(1)
bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' })
expect(frames).toHaveLength(2)
const msg = JSON.parse(
decodeFirstFrame(frames[1]).payload.toString()
) as JsonRpcNotification
expect(msg.method).toBe('pty.data')
} finally {
bulkDispatcher.dispose()
}
})
it('releases a parked bulk send when the dispatcher is disposed', async () => {
const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} })
const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 })
await vi.advanceTimersByTimeAsync(0)
bulkDispatcher.dispose()
await expect(pending).resolves.toBeUndefined()
})
it('targets only the requested client and resolves for missing clients', async () => {
const primaryFrames: Buffer[] = []
const secondaryFrames: Buffer[] = []
const bulkDispatcher = new RelayDispatcher((data) => {
primaryFrames.push(Buffer.from(data))
return true
})
try {
const secondaryId = bulkDispatcher.attachClient((data) => {
secondaryFrames.push(Buffer.from(data))
return true
})
await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId })
expect(primaryFrames).toHaveLength(0)
expect(secondaryFrames).toHaveLength(1)
await expect(
bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 })
).resolves.toBeUndefined()
expect(primaryFrames).toHaveLength(0)
expect(secondaryFrames).toHaveLength(1)
} finally {
bulkDispatcher.dispose()
}
})
})
})
+126 -11
View File
@@ -27,10 +27,28 @@ export type MethodHandler = (
export type NotificationHandler = (params: Record<string, unknown>, context: RequestContext) => void
/** Sink write. Returning literal `false` signals saturation (Node stream
* semantics); `void`/`true` mean the frame was accepted. */
export type RelayClientWrite = (data: Buffer) => boolean | void
export type RelayClientSinkOptions = {
/** One-shot: invoke `cb` once when the sink can accept more data again
* (stream 'drain'), or when the sink is permanently dead (error/close) so
* bulk senders waiting on it never hang. */
waitWriteDrain?: (cb: () => void) => void
}
type RelayClient = {
id: number
decoder: FrameDecoder
write: (data: Buffer) => void
write: RelayClientWrite
waitWriteDrain?: (cb: () => void) => void
/** Pending resolvers for bulk sends stalled on sink saturation. Flushed on
* drain, write failure, detach, setWrite, and dispose so no pump hangs. */
drainWaiters: Set<() => void>
/** Serializes bulk-lane sends per client so at most one bulk frame is
* admitted past the sink's high-water mark at a time. */
bulkChain: Promise<void>
nextOutgoingSeq: number
highestReceivedSeq: number
generation: number
@@ -58,8 +76,8 @@ export class RelayDispatcher {
private nextClientId = 1
private nextRequestId = 1
constructor(write: (data: Buffer) => void) {
this.primaryClient = this.createClient(write)
constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) {
this.primaryClient = this.createClient(write, sinkOptions)
this.clients.set(this.primaryClient.id, this.primaryClient)
this.startKeepalive()
}
@@ -75,10 +93,14 @@ export class RelayDispatcher {
// never acks the new client's frames until the new client's seq catches
// up - causing the client's unacked-timeout checker to accumulate stale
// timestamps that could eventually fire a false connection-dead signal.
setWrite(write: (data: Buffer) => void): void {
setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void {
this.requestAborts.abortClient(this.primaryClient.id)
this.primaryClient.write = write
this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain
this.primaryClient.closed = false
// Why: the saturated sink the waiters were parked on no longer exists;
// wake stalled bulk senders so they re-evaluate against the new sink.
this.flushDrainWaiters(this.primaryClient)
this.resetClient(this.primaryClient)
}
@@ -89,14 +111,15 @@ export class RelayDispatcher {
this.requestAborts.abortClient(this.primaryClient.id)
this.primaryClient.generation++
this.primaryClient.closed = true
this.flushDrainWaiters(this.primaryClient)
this.notifyClientDetached(this.primaryClient.id)
}
// Why: synced remote workspaces can have more than one Orca client attached
// to the same relay. Frame sequence numbers and JSON-RPC request ids are per
// SSH channel, so each socket client needs independent protocol state.
attachClient(write: (data: Buffer) => void): number {
const client = this.createClient(write)
attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number {
const client = this.createClient(write, sinkOptions)
this.clients.set(client.id, client)
return client.id
}
@@ -109,6 +132,7 @@ export class RelayDispatcher {
this.requestAborts.abortClient(clientId)
client.generation++
client.closed = true
this.flushDrainWaiters(client)
this.clients.delete(clientId)
this.notifyClientDetached(clientId)
}
@@ -165,6 +189,90 @@ export class RelayDispatcher {
}
}
/**
* Bulk-lane notification. Sends are serialized per client and the returned
* promise resolves only after the sink accepted the frame without reporting
* saturation (or the client went away). Bulk producers (file streams) await
* this between frames so interactive frames (pty.data echo) never queue
* behind an unbounded backlog on the shared SSH channel.
*
* With `clientId`, the frame goes only to that client — stream chunks have
* exactly one consumer, and broadcasting them would let one slow secondary
* client stall everyone. A missing/closed target resolves immediately; the
* caller's staleness check owns aborting the stream.
*/
notifyBulk(
method: string,
params?: Record<string, unknown>,
opts?: { clientId?: number }
): Promise<void> {
if (this.disposed) {
return Promise.resolve()
}
const msg: JsonRpcNotification = {
jsonrpc: '2.0',
method,
...(params !== undefined ? { params } : {})
}
const targets =
opts?.clientId !== undefined
? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined)
: Array.from(this.clients.values())
const waits: Promise<void>[] = []
for (const client of targets) {
if (client.closed) {
continue
}
// Why: the frame is encoded inside the chain step, not at call time —
// sequence numbers must be assigned in actual write order.
const step = client.bulkChain.then(() => {
if (this.disposed || client.closed) {
return
}
const accepted = this.sendFrame(client, msg)
if (accepted === false) {
return this.waitForClientDrain(client)
}
return undefined
})
client.bulkChain = step.catch(() => {})
waits.push(step)
}
if (waits.length === 0) {
return Promise.resolve()
}
return Promise.all(waits).then(() => {})
}
private waitForClientDrain(client: RelayClient): Promise<void> {
if (this.disposed || client.closed || !client.waitWriteDrain) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
let settled = false
const finish = (): void => {
if (settled) {
return
}
settled = true
client.drainWaiters.delete(finish)
resolve()
}
client.drainWaiters.add(finish)
try {
client.waitWriteDrain!(finish)
} catch {
finish()
}
})
}
private flushDrainWaiters(client: RelayClient): void {
for (const waiter of Array.from(client.drainWaiters)) {
waiter()
}
}
requestPrimary(
method: string,
params?: Record<string, unknown>,
@@ -236,14 +344,20 @@ export class RelayDispatcher {
// Why: dispose means this relay instance cannot send responses anymore;
// abort in-flight request work so stale SSH-side scans/watchers release.
this.requestAborts.abortAll()
for (const client of this.clients.values()) {
this.flushDrainWaiters(client)
}
}
private createClient(write: (data: Buffer) => void): RelayClient {
private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient {
const id = this.nextClientId++
const client: RelayClient = {
id,
decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)),
write,
waitWriteDrain: sinkOptions?.waitWriteDrain,
drainWaiters: new Set(),
bulkChain: Promise.resolve(),
nextOutgoingSeq: 1,
highestReceivedSeq: 0,
generation: 0,
@@ -387,13 +501,13 @@ export class RelayDispatcher {
private sendFrame(
client: RelayClient,
msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
): void {
): boolean | void {
if (this.disposed || client.closed) {
return
}
const seq = client.nextOutgoingSeq++
const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq)
this.writeFrame(client, frame)
return this.writeFrame(client, frame)
}
private startKeepalive(): void {
@@ -416,12 +530,13 @@ export class RelayDispatcher {
this.keepaliveTimer.unref()
}
private writeFrame(client: RelayClient, frame: Buffer): void {
private writeFrame(client: RelayClient, frame: Buffer): boolean | void {
try {
client.write(frame)
return client.write(frame)
} catch (err) {
client.closed = true
client.generation++
this.flushDrainWaiters(client)
if (client !== this.primaryClient) {
this.clients.delete(client.id)
this.notifyClientDetached(client.id)
+45 -5
View File
@@ -1,7 +1,7 @@
import { open, readFile, stat } from 'node:fs/promises'
import { extname } from 'node:path'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol'
import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry'
import {
BINARY_PROBE_BYTES,
@@ -61,11 +61,21 @@ type StreamChunkReader = {
): Promise<{ bytesRead: number }>
}
export type StreamPumpOptions = {
/** Client that requested the stream. Chunks go only to it — broadcasting
* bulk frames would let one slow secondary client stall the requester. */
clientId?: number
/** True when the client declared `flowControl: 'ack'` — it sends
* fs.streamAck per processed chunk and the pump caps unacked chunks. */
paceWithAcks: boolean
}
export async function readRelayFileStreamMetadata(
filePath: string,
dispatcher: RelayDispatcher,
registry: RelayStreamRegistry,
context: RequestContext
context: RequestContext,
pumpOptions?: StreamPumpOptions
): Promise<StreamMetadata> {
const stats = await stat(filePath)
const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()]
@@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata(
// Why: pumpChunks owns its own try/finally for handle release; the outer
// setImmediate kicks the pump off the metadata-response task so the client
// sees the response before the first chunk frame.
const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false }
setImmediate(() => {
void pumpChunks(streamId, stats.size, dispatcher, registry, context)
void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions)
})
return {
@@ -126,7 +137,8 @@ async function pumpChunks(
totalSize: number,
dispatcher: RelayDispatcher,
registry: RelayStreamRegistry,
context: RequestContext
context: RequestContext,
pumpOptions: StreamPumpOptions
): Promise<void> {
const entry = registry.get(streamId)
if (!entry) {
@@ -150,6 +162,27 @@ async function pumpChunks(
endReason = 'aborted'
break
}
// Why: credit window — bulk chunks share one ordered SSH channel with
// interactive pty.data frames. Waiting for client acks bounds how many
// stream bytes a keystroke echo can queue behind, and yields the relay
// event loop so incoming keystrokes are handled between chunks.
if (pumpOptions.paceWithAcks) {
while (
seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS &&
!context.isStale() &&
!registry.isAborted(streamId)
) {
await registry.waitForAck(streamId)
}
if (context.isStale()) {
endReason = 'stale'
break
}
if (registry.isAborted(streamId)) {
endReason = 'aborted'
break
}
}
const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset)
const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset)
if (bytesRead !== want) {
@@ -167,7 +200,14 @@ async function pumpChunks(
break
}
const data = buffer.subarray(0, bytesRead).toString('base64')
dispatcher.notify('fs.streamChunk', { streamId, seq, data })
// Why: the bulk lane waits out sink saturation, so a flood of chunk
// frames cannot pile up in the outbound pipe ahead of interactive
// pty.data frames written via plain notify().
await dispatcher.notifyBulk(
'fs.streamChunk',
{ streamId, seq, data },
pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined
)
offset += bytesRead
seq += 1
}
+58
View File
@@ -34,6 +34,9 @@ function createMockDispatcher() {
notify: vi.fn((method: string, params?: Record<string, unknown>) => {
notifications.push({ method, params })
}),
notifyBulk: vi.fn(async (method: string, params?: Record<string, unknown>): Promise<void> => {
notifications.push({ method, params })
}),
_notifications: notifications,
callRequest(
method: string,
@@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => {
expect(err).toBeNull()
})
it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => {
const filePath = path.join(tmpDir, 'paced.png')
const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks
writeFileSync(filePath, content)
const meta = (await dispatcher.callRequest(
'fs.readFileStream',
{ filePath, flowControl: 'ack' },
{ isStale: () => false }
)) as { streamId: number }
// Window of 4 admits seqs 0..3; seq 4 must wait for an ack.
await waitFor(() => collectStream(dispatcher).chunks.length === 4)
await flush(10)
expect(collectStream(dispatcher).chunks.length).toBe(4)
expect(collectStream(dispatcher).end).toBeNull()
// Ack one chunk → exactly one more is admitted.
dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 })
await waitFor(() => collectStream(dispatcher).chunks.length === 5)
await flush(10)
expect(collectStream(dispatcher).chunks.length).toBe(5)
for (let seq = 1; seq < 6; seq += 1) {
dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq })
}
await waitFor(() => collectStream(dispatcher).end !== null)
const { chunks, err } = collectStream(dispatcher)
expect(err).toBeNull()
const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64')))
expect(reassembled.equals(content)).toBe(true)
})
it('releases a pump parked on the ack window when the stream is cancelled', async () => {
const filePath = path.join(tmpDir, 'parked-cancel.png')
writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks
const meta = (await dispatcher.callRequest(
'fs.readFileStream',
{ filePath, flowControl: 'ack' },
{ isStale: () => false }
)) as { streamId: number }
await waitFor(() => collectStream(dispatcher).chunks.length === 4)
dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId })
const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry
await waitFor(() => registry.size() === 0)
const { end, err } = collectStream(dispatcher)
expect(end).toBeNull()
expect(err).toBeNull()
})
it('rejects the 17th concurrent stream with TooManyStreams', async () => {
const paths: string[] = []
for (let i = 0; i < 17; i++) {
+23 -2
View File
@@ -86,7 +86,13 @@ export class FsHandler {
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
this.dispatcher = dispatcher
this.registerHandlers()
this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId))
this.dispatcher.onClientDetached?.((clientId) => {
this.releaseClientWatches(clientId)
// Why: a detached client's fs.streamAck frames will never arrive; wake
// any pump parked on the ack window so it re-checks staleness and exits
// instead of stranding its open file handle.
this.streamRegistry.wakeAllAckWaiters()
})
}
private registerHandlers(): void {
@@ -113,6 +119,7 @@ export class FsHandler {
this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context))
this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context))
this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p))
this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p))
}
private async readDir(params: Record<string, unknown>) {
@@ -148,7 +155,13 @@ export class FsHandler {
private async readFileStream(params: Record<string, unknown>, context?: RequestContext) {
const filePath = expandTilde(params.filePath as string)
const ctx = context ?? { clientId: 0, isStale: () => false }
return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx)
return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, {
// Why: only target the requesting client when the dispatcher actually
// routed this request (context present) — direct-call tests and legacy
// paths keep broadcast semantics.
...(context ? { clientId: context.clientId } : {}),
paceWithAcks: params.flowControl === 'ack'
})
}
private async tempDir(): Promise<string> {
@@ -162,6 +175,14 @@ export class FsHandler {
}
}
private streamAck(params: Record<string, unknown>): void {
const streamId = params.streamId as number | undefined
const seq = params.seq as number | undefined
if (typeof streamId === 'number' && typeof seq === 'number') {
this.streamRegistry.recordAck(streamId, seq)
}
}
private async writeFile(params: Record<string, unknown>) {
const filePath = expandTilde(params.filePath as string)
const content = params.content as string
@@ -0,0 +1,295 @@
/**
* Regression: SSH typing latency under bulk file-stream load.
*
* The relay and the client share ONE ordered SSH channel. If the relay
* enqueues an entire file's fs.streamChunk frames into the outbound pipe
* at once, an interactive pty.data echo emitted mid-stream queues behind
* megabytes of bulk data and typing feels seconds-slow.
*
* These tests model the SSH channel as a congestible in-memory pipe and
* assert deterministic byte bounds instead of wall-clock latency:
* - with a saturated sink, the relay stalls the pump on write backpressure
* so at most ~1 chunk frame sits ahead of a pty echo;
* - with a fast sink but an unresponsive client, the fs.streamAck credit
* window bounds the in-flight backlog;
* - legacy clients that never ack still receive the full stream.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import * as path from 'node:path'
import { tmpdir } from 'node:os'
import { randomBytes } from 'node:crypto'
import {
SshChannelMultiplexer,
type MultiplexerTransport
} from '../main/ssh/ssh-channel-multiplexer'
import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader'
import { RelayDispatcher } from './dispatcher'
import { RelayContext } from './context'
import { FsHandler } from './fs-handler'
import { STREAM_CHUNK_SIZE } from './protocol'
// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header.
const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512
// Node pipe/socket sinks report saturation via write() === false past the HWM.
const SINK_HIGH_WATER_MARK = 64 * 1024
async function waitUntil(
predicate: () => boolean,
what: string,
timeoutMs = 10_000
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error(`waitUntil timed out: ${what}`)
}
await new Promise((r) => setImmediate(r))
}
}
/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */
async function waitUntilSettled(read: () => number, stableTurns = 25): Promise<void> {
let last = read()
let stable = 0
while (stable < stableTurns) {
await new Promise((r) => setImmediate(r))
const current = read()
if (current === last) {
stable += 1
} else {
stable = 0
last = current
}
}
}
type Harness = {
mux: SshChannelMultiplexer
dispatcher: RelayDispatcher
fsHandler: FsHandler
queuedBytes: () => number
/** Deliver every queued relay→client buffer to the client mux. */
deliverAll: () => void
/** Start delivering continuously on each macrotask turn. */
startAutoDeliver: () => void
dispose: () => void
}
function createHarness(opts: { congested: boolean }): Harness {
let relayFeed: ((data: Buffer) => void) | null = null
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const clientTransport: MultiplexerTransport = {
write: (data: Buffer) => {
// Client → relay: keystrokes and acks flow on the opposite direction of
// the duplex channel; they are not blocked by relay→client congestion.
setImmediate(() => relayFeed?.(data))
},
onData: (cb) => {
clientDataCallbacks.push(cb)
},
onClose: () => {}
}
const outQueue: Buffer[] = []
let queuedBytes = 0
const drainWaiters = new Set<() => void>()
const fireDrainIfIdle = (): void => {
if (queuedBytes > 0) {
return
}
for (const cb of Array.from(drainWaiters)) {
drainWaiters.delete(cb)
cb()
}
}
const dispatcher = new RelayDispatcher(
(data: Buffer) => {
outQueue.push(data)
queuedBytes += data.length
if (!opts.congested) {
return true
}
return queuedBytes < SINK_HIGH_WATER_MARK
},
{
waitWriteDrain: (cb: () => void) => {
drainWaiters.add(cb)
fireDrainIfIdle()
}
}
)
relayFeed = (data: Buffer) => dispatcher.feed(data)
const deliverAll = (): void => {
while (outQueue.length > 0) {
const buf = outQueue.shift()!
queuedBytes -= buf.length
for (const cb of clientDataCallbacks) {
cb(buf)
}
}
fireDrainIfIdle()
}
let autoDeliverTimer: ReturnType<typeof setInterval> | null = null
const startAutoDeliver = (): void => {
if (autoDeliverTimer) {
return
}
autoDeliverTimer = setInterval(deliverAll, 1)
}
const context = new RelayContext()
const fsHandler = new FsHandler(dispatcher, context)
const mux = new SshChannelMultiplexer(clientTransport)
return {
mux,
dispatcher,
fsHandler,
queuedBytes: () => queuedBytes,
deliverAll,
startAutoDeliver,
dispose: () => {
if (autoDeliverTimer) {
clearInterval(autoDeliverTimer)
}
mux.dispose()
dispatcher.dispose()
fsHandler.dispose()
}
}
}
describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => {
const harness = createHarness({ congested: true })
try {
const filePath = path.join(tmpDir, 'big.png')
const original = randomBytes(3 * 1024 * 1024) // 12 chunks
writeFileSync(filePath, original)
// Relay-side fake PTY: echoes input back immediately, mirroring
// PtyHandler's interactive fast path (dispatcher.notify on echo).
let queuedBytesAheadOfEcho = -1
harness.dispatcher.onNotification('pty.data', (params) => {
queuedBytesAheadOfEcho = harness.queuedBytes()
harness.dispatcher.notify('pty.data', { id: params.id, data: params.data })
})
const readPromise = readFileViaStream(harness.mux, filePath)
// Deliver until the client has the stream metadata, then congest fully.
await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued')
harness.deliverAll()
// Let the pump run to whatever bound it enforces (pre-fix: whole file).
await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued')
await waitUntilSettled(() => harness.queuedBytes())
// Type one key while the stream is congested.
harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' })
await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay')
// The echo must not sit behind an unbounded chunk backlog: at most one
// in-flight bulk frame (the write that saturated the sink) plus slack.
expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES)
// Un-congest: the stream must still complete with intact content.
harness.startAutoDeliver()
const result = await readPromise
expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true)
} finally {
harness.dispose()
}
}, 30_000)
it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => {
const harness = createHarness({ congested: false })
try {
const filePath = path.join(tmpDir, 'big.png')
writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks
const receivedSeqs: number[] = []
harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
receivedSeqs.push(params.seq as number)
})
let streamEnded = false
harness.mux.onNotificationByMethod('fs.streamEnd', () => {
streamEnded = true
})
harness.startAutoDeliver()
// Raw ack-capable request without sending any acks: models a client
// whose main thread is too busy to process chunks.
const metadata = (await harness.mux.request('fs.readFileStream', {
filePath,
flowControl: 'ack'
})) as { streamId: number }
await waitUntil(() => receivedSeqs.length > 0, 'first chunk received')
await waitUntilSettled(() => receivedSeqs.length)
// Without acks the relay must stop at the credit window, not flood
// the remaining chunks.
expect(receivedSeqs.length).toBeLessThanOrEqual(5)
expect(streamEnded).toBe(false)
// Acking releases the window and the stream completes.
const totalChunks = 12
for (let seq = 0; seq < totalChunks; seq += 1) {
harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq })
}
await waitUntil(() => streamEnded, 'stream completed after acks')
expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i))
} finally {
harness.dispose()
}
}, 30_000)
it('still delivers the full stream to legacy clients that never ack', async () => {
const harness = createHarness({ congested: false })
try {
const filePath = path.join(tmpDir, 'legacy.png')
const original = randomBytes(1024 * 1024 + 12345)
writeFileSync(filePath, original)
const chunks = new Map<number, Buffer>()
let streamEnded = false
harness.mux.onNotificationByMethod('fs.streamChunk', (params) => {
chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64'))
})
harness.mux.onNotificationByMethod('fs.streamEnd', () => {
streamEnded = true
})
harness.startAutoDeliver()
// Legacy request shape: no flowControl param, and no acks ever sent.
await harness.mux.request('fs.readFileStream', { filePath })
await waitUntil(() => streamEnded, 'legacy stream completed')
const reassembled = Buffer.concat(
Array.from(chunks.entries())
.sort(([a], [b]) => a - b)
.map(([, buf]) => buf)
)
expect(reassembled.equals(original)).toBe(true)
} finally {
harness.dispose()
}
}, 30_000)
})
+67 -2
View File
@@ -1,9 +1,14 @@
import type { FileHandle } from 'node:fs/promises'
import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol'
import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol'
type StreamEntry = {
handle: FileHandle
aborted: boolean
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
ackedThroughSeq: number
/** Pumps parked on the ack credit window. Woken by acks, abort, release,
* and a periodic stall recheck so a vanished client cannot strand a pump. */
ackWaiters: Set<() => void>
}
export class TooManyStreamsError extends Error {
@@ -22,7 +27,12 @@ export class RelayStreamRegistry {
throw new TooManyStreamsError()
}
const streamId = this.nextId++
this.streams.set(streamId, { handle, aborted: false })
this.streams.set(streamId, {
handle,
aborted: false,
ackedThroughSeq: -1,
ackWaiters: new Set()
})
return streamId
}
@@ -30,6 +40,7 @@ export class RelayStreamRegistry {
const entry = this.streams.get(streamId)
if (entry) {
entry.aborted = true
this.wakeAckWaiters(entry)
}
}
@@ -41,11 +52,65 @@ export class RelayStreamRegistry {
return this.streams.get(streamId)
}
recordAck(streamId: number, seq: number): void {
const entry = this.streams.get(streamId)
if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) {
return
}
if (seq > entry.ackedThroughSeq) {
entry.ackedThroughSeq = seq
}
this.wakeAckWaiters(entry)
}
ackedThroughSeq(streamId: number): number {
return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER
}
/** Resolves on the next ack/abort/release for this stream, or after the
* stall-recheck interval so callers can re-evaluate staleness. */
waitForAck(streamId: number): Promise<void> {
const entry = this.streams.get(streamId)
if (!entry || entry.aborted) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
let settled = false
const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS)
timer.unref?.()
const finish = (): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
entry.ackWaiters.delete(finish)
resolve()
}
entry.ackWaiters.add(finish)
})
}
/** Wake every parked pump (all streams) so it re-checks staleness — used
* when a client detaches and its acks will never arrive. */
wakeAllAckWaiters(): void {
for (const entry of this.streams.values()) {
this.wakeAckWaiters(entry)
}
}
private wakeAckWaiters(entry: StreamEntry): void {
for (const waiter of Array.from(entry.ackWaiters)) {
waiter()
}
}
async release(streamId: number): Promise<void> {
const entry = this.streams.get(streamId)
if (!entry) {
return
}
this.wakeAckWaiters(entry)
this.streams.delete(streamId)
try {
await entry.handle.close()
+107 -21
View File
@@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000
export const STREAM_CHUNK_SIZE = 256 * 1024
export const MAX_CONCURRENT_STREAMS = 16
/** Max unacked fs.streamChunk frames in flight per stream when the client
* requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive
* pty.data frame can queue behind on the shared SSH channel (~1MB raw) while
* keeping the pipe full across one ack round-trip on fast links. */
export const STREAM_ACK_WINDOW_CHUNKS = 4
/** Safety-valve poll interval for a pump stalled on acks: re-checks stream
* abort/staleness so a client that vanished mid-stream cannot park the pump
* (and its open file handle) forever. */
export const STREAM_ACK_STALL_RECHECK_MS = 1_000
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007
@@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
}
export class FrameDecoder {
private buffer = Buffer.alloc(0)
// Why: feed() sits on the hot receive path. Rebuilding one contiguous
// buffer per feed (Buffer.concat) re-copies every already-buffered byte for
// each incoming chunk — O(n²) per large frame. A chunk list assembles each
// frame exactly once instead.
private chunks: Buffer[] = []
private bufferedLength = 0
private onFrame: (frame: DecodedFrame) => void
private onError: ((err: Error) => void) | null
@@ -119,23 +135,32 @@ export class FrameDecoder {
}
feed(chunk: Buffer | Uint8Array): void {
this.buffer = Buffer.concat([this.buffer, chunk])
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
if (buf.length > 0) {
this.chunks.push(buf)
this.bufferedLength += buf.length
}
while (this.buffer.length >= HEADER_LENGTH) {
const length = this.buffer.readUInt32BE(9)
while (this.bufferedLength >= HEADER_LENGTH) {
const header = this.peekBytes(HEADER_LENGTH)
const length = header.readUInt32BE(9)
const totalLength = HEADER_LENGTH + length
if (this.bufferedLength < totalLength) {
// Not fully received yet (also holds oversized frames until they can
// be skipped whole, keeping the decoder synchronized).
break
}
if (length > MAX_MESSAGE_SIZE) {
// Why: Throwing here would leave the buffer in a partially consumed
// state — subsequent feed() calls would try to parse the leftover
// payload bytes as a new header, corrupting every future frame.
// Instead we skip the entire oversized frame so the decoder stays
// synchronized with the stream.
if (this.buffer.length < totalLength) {
// Haven't received the full oversized payload yet; wait for more data.
break
}
this.buffer = this.buffer.subarray(totalLength)
this.discardBytes(totalLength)
const err = new Error(`Frame payload too large: ${length} bytes — discarded`)
if (this.onError) {
this.onError(err)
@@ -145,23 +170,20 @@ export class FrameDecoder {
continue
}
if (this.buffer.length < totalLength) {
break
}
const framed = this.takeBytes(totalLength)
const frame: DecodedFrame = {
type: this.buffer[0],
id: this.buffer.readUInt32BE(1),
ack: this.buffer.readUInt32BE(5),
payload: this.buffer.subarray(HEADER_LENGTH, totalLength)
type: framed[0],
id: framed.readUInt32BE(1),
ack: framed.readUInt32BE(5),
payload: framed.subarray(HEADER_LENGTH, totalLength)
}
this.buffer = this.buffer.subarray(totalLength)
this.onFrame(frame)
}
}
reset(): void {
this.buffer = Buffer.alloc(0)
this.chunks = []
this.bufferedLength = 0
}
// Why: at the handshake → dispatcher transition, the next consumer must
@@ -169,10 +191,74 @@ export class FrameDecoder {
// frame. This returns and clears the decoder's internal residue so the
// caller can hand it to the dispatcher (or stdout pipe) without loss.
drain(): Buffer {
const out = this.buffer
this.buffer = Buffer.alloc(0)
const out =
this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength)
this.reset()
return out
}
/** View of the first `count` buffered bytes without consuming them. */
private peekBytes(count: number): Buffer {
const first = this.chunks[0]
if (first.length >= count) {
return first
}
const out = Buffer.allocUnsafe(count)
let copied = 0
for (const part of this.chunks) {
copied += part.copy(out, copied, 0, Math.min(part.length, count - copied))
if (copied >= count) {
break
}
}
return out
}
/** Consume and return the first `count` buffered bytes (single copy). */
private takeBytes(count: number): Buffer {
const first = this.chunks[0]
if (first.length === count) {
this.chunks.shift()
this.bufferedLength -= count
return first
}
if (first.length > count) {
this.chunks[0] = first.subarray(count)
this.bufferedLength -= count
return first.subarray(0, count)
}
const out = Buffer.allocUnsafe(count)
let copied = 0
while (copied < count) {
const part = this.chunks[0]
const take = Math.min(part.length, count - copied)
part.copy(out, copied, 0, take)
copied += take
if (take === part.length) {
this.chunks.shift()
} else {
this.chunks[0] = part.subarray(take)
}
}
this.bufferedLength -= count
return out
}
/** Consume the first `count` buffered bytes without assembling them. */
private discardBytes(count: number): void {
let remaining = count
while (remaining > 0) {
const part = this.chunks[0]
if (part.length <= remaining) {
this.chunks.shift()
remaining -= part.length
} else {
this.chunks[0] = part.subarray(remaining)
remaining = 0
}
}
this.bufferedLength -= count
}
}
export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage {
+69 -1
View File
@@ -14,7 +14,10 @@ import {
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
mockPtyInstance: {
pid: 12345,
// Why: attach now proves the backing pid is alive before replaying, so the
// default managed PTY must report a live pid. Reuse the test runner's own
// pid — always alive — so unrelated attach tests are not seen as dead.
pid: process.pid,
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
@@ -609,6 +612,71 @@ describe('PtyHandler', () => {
}
)
it('reports not-found and reaps a reattach whose backing shell is dead', async () => {
// Why: a lingering managed entry whose child died without an onExit would
// otherwise attach-succeed with an empty replay and strand the pane on a
// black shell. A provably-dead pid must surface as not-found so the SSH
// provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh.
let onExitCb: ((evt: { exitCode: number }) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn(),
onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
onExitCb = cb
})
})
const exits: { id: string; paneKey?: string }[] = []
handler.setExitListener((evt) => exits.push(evt))
await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } })
expect(handler.activePtyCount).toBe(1)
expect(onExitCb).toBeDefined()
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false)
try {
await expect(
dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
).rejects.toThrow('PTY "pty-1" not found')
} finally {
aliveSpy.mockRestore()
}
// The stale entry is reaped: cache-eviction observers fire and the map slot
// is freed so a later attach also cleanly reports not-found.
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }])
expect(handler.activePtyCount).toBe(0)
await expect(
dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true })
).rejects.toThrow('PTY "pty-1" not found')
})
it('replays for a reattach whose backing shell is still alive', async () => {
// Guard the other direction: a live pid must never be reaped, so a quiet
// but live session still returns its buffered replay on reattach.
let dataCallback: ((data: string) => void) | undefined
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
onData: vi.fn((cb: (data: string) => void) => {
dataCallback = cb
}),
onExit: vi.fn()
})
await dispatcher.callRequest('pty.spawn', {})
dataCallback?.('prompt$ ')
const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true)
try {
const result = await dispatcher.callRequest('pty.attach', {
id: 'pty-1',
suppressReplayNotification: true
})
expect(result).toEqual({ replay: 'prompt$ ' })
} finally {
aliveSpy.mockRestore()
}
expect(handler.activePtyCount).toBe(1)
})
it('terminates spawned PTY when request becomes stale before response', async () => {
const killSpy = vi.fn()
const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() }
+17
View File
@@ -10,6 +10,7 @@ import {
resolveProcessCwd,
processHasChildren,
getForegroundProcessName,
isProcessAlive,
listShellProfiles
} from './pty-shell-utils'
import { getRelayShellLaunchConfig } from './pty-shell-launch'
@@ -692,6 +693,22 @@ export class PtyHandler {
throw new Error(`PTY "${id}" not found`)
}
// Why: a reattach can arrive for a relay PTY whose backing shell already
// died without node-pty delivering onExit (e.g. the child was reaped out of
// band while the SSH channel was down). The map entry lingers, so attach
// would otherwise "succeed" with an empty replay and strand the reattached
// pane on a black, unresponsive shell. Prove liveness here; if the pid is
// provably gone, reap the stale entry and report not-found so the caller
// drops the dead lease and spawns fresh — the same recovery path an expired
// grace window already takes.
if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) {
this.notifyExitListener(managed)
disposeManagedPty(managed)
this.ptys.delete(id)
this.clearPtyFlowState(id)
throw new Error(`PTY "${id}" not found`)
}
// Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the
// relay changes generation — an app update deploys a new content-hashed
// relay dir, or a grace-expired relay restarts — the counter resets, so an
+35
View File
@@ -11,6 +11,7 @@ vi.mock('child_process', () => ({
import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot'
import {
getForegroundProcessName,
isProcessAlive,
resolveDefaultCwd,
resolveWindowsDefaultShell
} from './pty-shell-utils'
@@ -51,6 +52,40 @@ beforeEach(() => {
resetProcessTableSnapshotForTests()
})
describe('isProcessAlive', () => {
it('reports the test runner process as alive', () => {
expect(isProcessAlive(process.pid)).toBe(true)
})
it('reports a process as dead ONLY on ESRCH', () => {
// Why: attach() reaps a lingering managed PTY based on this, so a false
// positive would kill a live remote shell. Only "no such process" counts.
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('no such process') as NodeJS.ErrnoException
err.code = 'ESRCH'
throw err
})
try {
expect(isProcessAlive(2147483646)).toBe(false)
} finally {
spy.mockRestore()
}
})
it('treats an unsignalable process (EPERM) as alive', () => {
const spy = vi.spyOn(process, 'kill').mockImplementation(() => {
const err = new Error('operation not permitted') as NodeJS.ErrnoException
err.code = 'EPERM'
throw err
})
try {
expect(isProcessAlive(1)).toBe(true)
} finally {
spy.mockRestore()
}
})
})
describe('resolveWindowsDefaultShell', () => {
it('uses an existing SHELL override when one is provided', () => {
expect(
+13
View File
@@ -147,6 +147,19 @@ export async function processHasChildren(pid: number): Promise<boolean> {
}
}
// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no
// such process") proves the pid is gone; EPERM means it exists but is
// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so
// a liveness check can only ever declare a *provably* dead process dead.
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
function collectDescendants(
rows: ProcessTableRow[],
rootPid: number
+64 -8
View File
@@ -373,15 +373,44 @@ async function main(): Promise<void> {
// write to a dead pipe, silently failing or throwing EPIPE. When a
// socket client reconnects, setWrite swaps the callback to the socket.
let stdoutAlive = true
const dispatcher = new RelayDispatcher((data) => {
if (stdoutAlive) {
// Why: one-shot waiters parked by the dispatcher's bulk lane when stdout
// reports saturation (write() === false). Flushed on 'drain' and on every
// stdout-death path so a stalled file-stream pump never outlives the pipe
// it was waiting on.
const stdoutDrainWaiters = new Set<() => void>()
const flushStdoutDrainWaiters = (): void => {
for (const cb of Array.from(stdoutDrainWaiters)) {
stdoutDrainWaiters.delete(cb)
cb()
}
}
process.stdout.on('drain', flushStdoutDrainWaiters)
const dispatcher = new RelayDispatcher(
(data) => {
if (!stdoutAlive) {
return
}
try {
process.stdout.write(data)
// Why: surface Node's backpressure signal to the dispatcher so bulk
// frames (fs.streamChunk) wait for drain instead of queueing megabytes
// ahead of interactive pty.data frames on the SSH channel.
return process.stdout.write(data)
} catch {
stdoutAlive = false
flushStdoutDrainWaiters()
return undefined
}
},
{
waitWriteDrain: (cb) => {
if (!stdoutAlive) {
cb()
return
}
stdoutDrainWaiters.add(cb)
}
}
})
)
const context = new RelayContext()
@@ -680,11 +709,35 @@ async function main(): Promise<void> {
)
cancelGrace('socket client accepted')
const clientId = dispatcher.attachClient((data) => {
if (!sock.destroyed) {
sock.write(data)
// Why: same backpressure surface as the stdout sink — bulk frames wait
// for the socket to drain so they cannot bury interactive PTY frames.
const sockDrainWaiters = new Set<() => void>()
const flushSockDrainWaiters = (): void => {
for (const cb of Array.from(sockDrainWaiters)) {
sockDrainWaiters.delete(cb)
cb()
}
})
}
sock.on('drain', flushSockDrainWaiters)
sock.on('close', flushSockDrainWaiters)
sock.on('error', flushSockDrainWaiters)
const clientId = dispatcher.attachClient(
(data) => {
if (!sock.destroyed) {
return sock.write(data)
}
return undefined
},
{
waitWriteDrain: (cb) => {
if (sock.destroyed) {
cb()
return
}
sockDrainWaiters.add(cb)
}
}
)
socketClients.set(sock, clientId)
// Why: bytes that arrived in the same TCP send as the handshake frame
@@ -889,6 +942,7 @@ async function main(): Promise<void> {
// before the grace period starts.
process.stdout.on('error', () => {
stdoutAlive = false
flushStdoutDrainWaiters()
dispatcher.invalidateClient()
})
@@ -934,6 +988,7 @@ async function main(): Promise<void> {
// dead so the primary client write callback becomes a no-op while
// socket clients, if any, keep their own live transports.
stdoutAlive = false
flushStdoutDrainWaiters()
dispatcher.invalidateClient()
if (socketClients.size === 0) {
startGrace('stdin ended')
@@ -942,6 +997,7 @@ async function main(): Promise<void> {
process.stdin.on('error', () => {
stdoutAlive = false
flushStdoutDrainWaiters()
dispatcher.invalidateClient()
if (socketClients.size === 0) {
startGrace('stdin error')
+59 -2
View File
@@ -101,6 +101,8 @@
--color-status-success: var(--status-success);
--color-status-success-background: var(--status-success-background);
--color-status-success-border: var(--status-success-border);
--color-tab-group-split-divider: var(--tab-group-split-divider);
--color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong);
--color-git-graph-ref: var(--git-graph-ref);
--color-git-graph-remote-ref: var(--git-graph-remote-ref);
--color-git-graph-base-ref: var(--git-graph-base-ref);
@@ -202,6 +204,11 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #f59e0b;
/* Why: tab-group splits use a dedicated divider — `--border` is too faint on
card/editor surfaces when panes are side-by-side or stacked. Default must
meet >=3:1 against `--card`, not only the hover/drag strong state. */
--tab-group-split-divider: #868690;
--tab-group-split-divider-strong: #71717a;
}
/* ── Dark Mode ───────────────────────────────────────── */
@@ -285,6 +292,9 @@
--git-graph-lane-4: #40b0a6;
--git-graph-lane-5: #b66dff;
--annotation-highlight: #fbbf24;
/* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
--tab-group-split-divider: #71717a;
--tab-group-split-divider-strong: #a1a1aa;
}
.linear-priority-bars {
@@ -618,6 +628,52 @@
overflow: hidden;
}
/* Tab-group split resize handle — wide hit area, visible center line. */
.tab-group-split-resize-handle {
flex-shrink: 0;
position: relative;
background: transparent;
}
.tab-group-split-resize-handle.is-vertical {
width: 6px;
cursor: col-resize;
}
.tab-group-split-resize-handle.is-horizontal {
height: 6px;
cursor: row-resize;
}
.tab-group-split-resize-handle::after {
content: '';
position: absolute;
border-radius: 1px;
background: var(--tab-group-split-divider);
transition: background 100ms ease;
}
.tab-group-split-resize-handle.is-vertical::after {
top: 0;
bottom: 0;
left: 50%;
width: 3px;
transform: translateX(-50%);
}
.tab-group-split-resize-handle.is-horizontal::after {
left: 0;
right: 0;
top: 50%;
height: 3px;
transform: translateY(-50%);
}
.tab-group-split-resize-handle:hover::after,
.tab-group-split-resize-handle.is-dragging::after {
background: var(--tab-group-split-divider-strong);
}
/* ── Titlebar ────────────────────────────────────────── */
/* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the
@@ -1904,13 +1960,14 @@
text-underline-offset: 2px;
}
/* Why: Monaco owns Peek References scrollables and auto-hides faded needed
vertical thumbs while the widget stays open; revalidate these classes on upgrade. */
/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with
Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */
.monaco-editor
.reference-zone-widget
.monaco-scrollable-element
> .scrollbar.vertical.invisible.fade {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
@@ -1,7 +1,9 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAppStore } from '../store'
import { selectCodexRestartInputs } from './codex-restart-chip-inputs'
import { translate } from '@/i18n/i18n'
import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus'
import {
@@ -73,8 +75,14 @@ export default function CodexRestartChip({
worktreeId: string
}): React.JSX.Element | null {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
// Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId
// on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on
// pty teardown), so subscribe to them only while a restart notice actually
// exists. Otherwise this per-worktree chip re-rendered on every pty event to
// compute "no notice → render nothing". See codex-restart-chip-inputs.
const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore(
useShallow(selectCodexRestartInputs)
)
const staleWorktreePtyIds = useMemo(
() =>
collectStalePtyIdsForTabs({
@@ -3848,7 +3848,21 @@ function PRActionsPanel({
const actionItem = { ...item, state: localState }
const mergePresentation = presentGitHubPRMergeState(actionItem)
const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings)
const sourceSettings = getTaskSourceRuntimeSettings(sourceContext)
const repoOwnerSettings = useAppStore(
useShallow((s) => getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId ?? null))
)
const sourceSettings = useMemo(() => {
if (sourceContext?.provider !== 'github') {
return repoOwnerSettings
}
const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)
return sourceRuntimeSettings.activeRuntimeEnvironmentId
? ({
...repoOwnerSettings,
...sourceRuntimeSettings
} as typeof repoOwnerSettings)
: repoOwnerSettings
}, [repoOwnerSettings, sourceContext])
const mergeTarget = getActiveRuntimeTarget(sourceSettings)
const canMutateWithRepoContext =
!!repoPath || !!projectOrigin || mergeTarget.kind === 'environment'
+24 -4
View File
@@ -57,7 +57,7 @@ import {
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-repair'
import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility'
import {
getEffectiveLayoutForWorktree as getEffectiveLayout,
@@ -252,6 +252,7 @@ function Terminal(): React.JSX.Element | null {
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const activeTabId = useAppStore((s) => s.activeTabId)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
const setActiveTab = useAppStore((s) => s.setActiveTab)
@@ -720,19 +721,38 @@ function Terminal(): React.JSX.Element | null {
}, [queueEditorCloseRequests])
useEffect(() => {
if (!shouldRepairActiveTerminalTab({ activeTabType, activeTabId, tabs })) {
const rememberedTabId = renderedActiveWorktreeId
? (activeTabIdByWorktree[renderedActiveWorktreeId] ?? null)
: null
// Why: prefer the worktree's remembered active tab over the first tab so a
// repair firing on a transient worktree-switch render restores the tab the
// user left on instead of permanently resetting the selection to Terminal 1.
const repairedTabId = resolveRepairedActiveTerminalTabId({
activeTabType,
activeTabId,
rememberedTabId,
tabs
})
if (!repairedTabId) {
return
}
// Why: mutating Zustand during render trips React's "Cannot update a
// component while rendering a different component" warning. Keep the repair
// terminal-only so inactive CLI-created tabs cannot steal editor/browser focus.
setActiveTab(tabs[0].id)
setActiveTab(repairedTabId)
// Why: `tabs` is intentionally the dependency here because the repair must
// react to tab-order/content changes, not just scalar IDs. The list comes
// from Zustand selectors and is small in practice, so this explicit repair
// effect is preferred over duplicating reconciliation state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTabId, activeTabType, setActiveTab, tabs])
}, [
activeTabId,
activeTabType,
setActiveTab,
tabs,
activeTabIdByWorktree,
renderedActiveWorktreeId
])
// Track which worktrees have been activated during this app session.
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { shallow } from 'zustand/shallow'
import {
EMPTY_CODEX_RESTART_INPUTS,
selectCodexRestartInputs,
type CodexRestartInputsState
} from './codex-restart-chip-inputs'
describe('selectCodexRestartInputs', () => {
it('returns the frozen empty bundle while no restart notice exists', () => {
const state: CodexRestartInputsState = {
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
codexRestartNoticeByPtyId: {}
}
expect(selectCodexRestartInputs(state)).toBe(EMPTY_CODEX_RESTART_INPUTS)
// Churning EITHER map while no notice exists must NOT change the selected
// reference, so a useShallow subscription skips the re-render. This covers
// the pty-teardown path that re-spreads codexRestartNoticeByPtyId even empty.
const churnedPty: CodexRestartInputsState = {
...state,
ptyIdsByTabId: { 'tab-1': ['pty-2'], 'tab-2': ['pty-3'] }
}
const churnedNotice: CodexRestartInputsState = {
ptyIdsByTabId: state.ptyIdsByTabId,
codexRestartNoticeByPtyId: {} // fresh empty object, same as a teardown re-spread
}
expect(selectCodexRestartInputs(churnedPty)).toBe(EMPTY_CODEX_RESTART_INPUTS)
expect(selectCodexRestartInputs(churnedNotice)).toBe(EMPTY_CODEX_RESTART_INPUTS)
expect(shallow(selectCodexRestartInputs(state), selectCodexRestartInputs(churnedNotice))).toBe(
true
)
})
it('exposes both live maps the instant a restart notice exists', () => {
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const codexRestartNoticeByPtyId = { 'pty-1': {} as never }
const state: CodexRestartInputsState = { ptyIdsByTabId, codexRestartNoticeByPtyId }
const selected = selectCodexRestartInputs(state)
// Live references pass straight through so the stale-pty memo + notice lookup derive fully.
expect(selected.ptyIdsByTabId).toBe(ptyIdsByTabId)
expect(selected.codexRestartNoticeByPtyId).toBe(codexRestartNoticeByPtyId)
expect(selected).not.toBe(EMPTY_CODEX_RESTART_INPUTS)
})
it('shallow-changes only when a live map reference changes while a notice exists', () => {
const ptyIdsByTabId = { 'tab-1': ['pty-1'] }
const s1: CodexRestartInputsState = {
ptyIdsByTabId,
codexRestartNoticeByPtyId: { 'pty-1': {} as never }
}
const r1 = selectCodexRestartInputs(s1)
expect(shallow(r1, selectCodexRestartInputs(s1))).toBe(true)
const s2: CodexRestartInputsState = { ...s1, ptyIdsByTabId: { 'tab-1': ['pty-9'] } }
expect(shallow(r1, selectCodexRestartInputs(s2))).toBe(false)
})
})
@@ -0,0 +1,37 @@
import type { AppState } from '@/store/types'
export type CodexRestartInputsState = Pick<AppState, 'ptyIdsByTabId' | 'codexRestartNoticeByPtyId'>
export type CodexRestartInputs = CodexRestartInputsState
// Why: shared frozen bundle returned while no Codex restart notice exists — the
// overwhelmingly common case. CodexRestartChip is mounted once per worktree
// Terminal (visible AND hidden-measurable) and once per split group, and reads
// these two maps only to find panes carrying a restart notice, which appear only
// on a Codex account switch. BOTH churn on unrelated pty lifecycle:
// ptyIdsByTabId gets a fresh identity on every pty register/attach/detach, and
// codexRestartNoticeByPtyId is re-spread into a new object even when empty on the
// pty exit/teardown path (terminals.ts clearTabPtyId). Subscribing to either
// re-rendered every mounted chip on that churn. Gate both behind notice-existence
// so idle chips keep the same reference and stop reacting; behavior is identical
// (no notice -> [] stale ids -> null render). Frozen so the singleton can't be
// mutated.
export const EMPTY_CODEX_RESTART_INPUTS: CodexRestartInputs = Object.freeze({
ptyIdsByTabId: {},
codexRestartNoticeByPtyId: {}
})
/**
* Expose the pty and restart-notice maps to the chip only while at least one
* Codex restart notice is live; otherwise return a stable frozen bundle so a
* `useShallow` subscription skips re-renders on unrelated pty-lifecycle churn.
*/
export function selectCodexRestartInputs(s: CodexRestartInputsState): CodexRestartInputs {
if (Object.keys(s.codexRestartNoticeByPtyId).length === 0) {
return EMPTY_CODEX_RESTART_INPUTS
}
return {
ptyIdsByTabId: s.ptyIdsByTabId,
codexRestartNoticeByPtyId: s.codexRestartNoticeByPtyId
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
getSettingsForRepoRuntimeOwner,
type RepoRuntimeOwnerState
} from '../lib/repo-runtime-owner'
import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { getTaskSourceRuntimeSettings } from '../../../shared/task-source-context'
// Why: mirrors the merge-routing decision inside GitHubItemDialog's
// PRActionsPanel so a revert to source-only routing (issue #6957) fails here.
// A runtime-owned repo whose GitHub source view is local must still merge on
// the owner runtime, not fall back to local `gh:mergePR`.
function resolveMergeTarget(
state: RepoRuntimeOwnerState,
repoId: string | null,
sourceContext: TaskSourceContext | null
): ReturnType<typeof getActiveRuntimeTarget> {
const repoOwnerSettings = getSettingsForRepoRuntimeOwner(state, repoId)
const sourceSettings =
sourceContext?.provider !== 'github'
? repoOwnerSettings
: (() => {
const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)
return sourceRuntimeSettings.activeRuntimeEnvironmentId
? { ...repoOwnerSettings, ...sourceRuntimeSettings }
: repoOwnerSettings
})()
return getActiveRuntimeTarget(sourceSettings)
}
function githubSource(hostId: TaskSourceContext['hostId']): TaskSourceContext {
return { kind: 'task-source', provider: 'github', projectId: 'p', hostId, repoId: 'repo-1' }
}
describe('GitHubItemDialog PR merge routing', () => {
const runtimeOwnedRepo: RepoRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: null },
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }]
}
it('routes a runtime-owned repo to its owner runtime when the source view is local (#6957)', () => {
expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('local'))).toEqual({
kind: 'environment',
environmentId: 'owner-runtime'
})
})
it('routes a runtime-owned repo to its owner runtime when there is no source context', () => {
expect(resolveMergeTarget(runtimeOwnedRepo, 'repo-1', null)).toEqual({
kind: 'environment',
environmentId: 'owner-runtime'
})
})
it('lets a runtime source override the repo owner when both are runtimes', () => {
expect(
resolveMergeTarget(runtimeOwnedRepo, 'repo-1', githubSource('runtime:source-runtime'))
).toEqual({ kind: 'environment', environmentId: 'source-runtime' })
})
it('keeps an explicitly-local repo on local IPC even while a runtime is focused', () => {
const localRepo: RepoRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }]
}
expect(resolveMergeTarget(localRepo, 'repo-1', githubSource('local'))).toEqual({
kind: 'local'
})
})
})
@@ -91,8 +91,8 @@ describe('GitHubItemDialog source host boundaries', () => {
expect(source).toContain('sourceContext,')
expect(cacheKeySection).toContain('sourceCacheScope')
expect(source).toContain('getTaskSourceCacheScope(sourceContext)')
expect(matchInvalidationSection).toContain(
'if (removed) {\n workItemDetailsCacheGeneration += 1'
expect(matchInvalidationSection).toMatch(
/if \(removed\) {\s+workItemDetailsCacheGeneration \+= 1/
)
})
@@ -190,7 +190,13 @@ describe('GitHubItemDialog source host boundaries', () => {
'function CommentReactions'
)
expect(actionsSection).toContain('getTaskSourceRuntimeSettings(sourceContext)')
expect(actionsSection).toContain('getSettingsForRepoRuntimeOwner(s, repoId ?? item.repoId')
expect(actionsSection).toContain('...repoOwnerSettings')
expect(actionsSection).toContain(
'const sourceRuntimeSettings = getTaskSourceRuntimeSettings(sourceContext)'
)
expect(actionsSection).toContain('sourceRuntimeSettings.activeRuntimeEnvironmentId')
expect(actionsSection).toContain('...sourceRuntimeSettings')
expect(actionsSection).toContain('getActiveRuntimeTarget(sourceSettings)')
expect(actionsSection).toContain(
'const canMergeWithRepoContext = !!repoPath || mergeTarget.kind ==='
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import {
NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX,
setBoundedScopeCacheEntry
} from './native-chat-composer-scope-cache'
describe('setBoundedScopeCacheEntry', () => {
it('bounds the cache with LRU eviction, keeping re-set keys', () => {
const cache = new Map<string, number>()
setBoundedScopeCacheEntry(cache, 'keep', 1)
const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 20
for (let i = 0; i < total; i += 1) {
setBoundedScopeCacheEntry(cache, `scope-${i}`, i)
if (i % 10 === 0) {
setBoundedScopeCacheEntry(cache, 'keep', 1)
}
}
expect(cache.size).toBe(NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX)
expect(cache.has('scope-0')).toBe(false) // oldest untouched entry evicted
expect(cache.has('keep')).toBe(true) // periodically re-set → retained
expect(cache.has(`scope-${total - 1}`)).toBe(true) // most recent retained
})
it('moves a re-set key to most-recent and updates its value', () => {
const cache = new Map<string, number>()
setBoundedScopeCacheEntry(cache, 'a', 1)
setBoundedScopeCacheEntry(cache, 'b', 2)
setBoundedScopeCacheEntry(cache, 'a', 3)
expect([...cache.keys()]).toEqual(['b', 'a'])
expect(cache.get('a')).toBe(3)
})
})
@@ -0,0 +1,24 @@
// Shared LRU bound for the native-chat composer's per-scope caches (draft text
// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The
// caches exist so an in-progress message survives the composer unmounting on a
// TUI/GUI toggle, but a scope key for a permanently-removed pane is never
// revisited, so without a bound its unsent entry would linger for the renderer's
// whole session. delete-then-set keeps the actively-edited scope most-recent so
// eviction only sheds the oldest untouched scopes.
export const NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX = 128
export function setBoundedScopeCacheEntry<T>(
cache: Map<string, T>,
scopeKey: string,
value: T
): void {
cache.delete(scopeKey)
cache.set(scopeKey, value)
while (cache.size > NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
cache.delete(oldest)
}
}
@@ -4,6 +4,7 @@ import {
readNativeChatDraftCache,
writeNativeChatDraftCache
} from './native-chat-draft-cache'
import { NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX } from './native-chat-composer-scope-cache'
afterEach(() => {
clearNativeChatDraftCacheForTests()
@@ -26,4 +27,21 @@ describe('native-chat draft cache', () => {
writeNativeChatDraftCache('pty-1', '')
expect(readNativeChatDraftCache('pty-1')).toBe('')
})
it('bounds the cache so unsent drafts for removed panes cannot accumulate', () => {
writeNativeChatDraftCache('keep', 'hot')
const total = NATIVE_CHAT_COMPOSER_SCOPE_CACHE_MAX + 40
for (let i = 0; i < total; i += 1) {
writeNativeChatDraftCache(`scope-${i}`, `draft-${i}`)
if (i % 20 === 0) {
writeNativeChatDraftCache('keep', 'hot')
}
}
// Oldest untouched draft evicted; the actively-edited and most-recent survive.
expect(readNativeChatDraftCache('scope-0')).toBe('')
expect(readNativeChatDraftCache('keep')).toBe('hot')
expect(readNativeChatDraftCache(`scope-${total - 1}`)).toBe(`draft-${total - 1}`)
})
})
@@ -4,6 +4,8 @@
// the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors
// the attachment cache so both halves of an unsent message survive the toggle.
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
const draftCache = new Map<string, string>()
export function readNativeChatDraftCache(scopeKey: string): string {
@@ -17,7 +19,8 @@ export function writeNativeChatDraftCache(scopeKey: string, draft: string): void
draftCache.delete(scopeKey)
return
}
draftCache.set(scopeKey, draft)
// LRU-bounded so unsent drafts for permanently-removed panes can't accumulate.
setBoundedScopeCacheEntry(draftCache, scopeKey, draft)
}
export function clearNativeChatDraftCacheForTests(): void {
@@ -347,3 +347,42 @@ describe('applyCommandMarkerBoundaries', () => {
).toEqual(['new'])
})
})
describe('scope-cache key counts stay bounded (memory-leak regression)', () => {
// The per-key arrays were capped at 8, but the KEY count (paneKey/agent/session,
// all ephemeral) was unbounded, so distinct panes/sessions accumulated forever.
// Both caches now LRU-bound the key count at 128 (shared helper, #7566).
const CAP = 128
it('appendCommandMarkerCache evicts the oldest scope key past the cap', () => {
clearCommandMarkerCacheForTests()
for (let i = 0; i < CAP + 5; i++) {
appendCommandMarkerCache(
{ paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${i}` },
'/clear'
)
}
// Oldest sessions evicted; the most-recent CAP survive.
expect(
readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's0' })
).toEqual([])
expect(
readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: 's4' })
).toEqual([])
expect(
readCommandMarkerCache({ paneKey: 'tab:leaf', agent: 'claude', sessionId: `s${CAP + 4}` })
).toHaveLength(1)
})
it('writePendingSendCache evicts the oldest scope key past the cap', () => {
clearPendingSendCacheForTests()
const send = (id: string): NativeChatPendingSend => ({ id, text: id, sentAt: 1 })
for (let i = 0; i < CAP + 5; i++) {
writePendingSendCache({ paneKey: `tab-${i}:leaf`, agent: 'claude' }, [send(`m${i}`)])
}
expect(readPendingSendCache({ paneKey: 'tab-0:leaf', agent: 'claude' })).toEqual([])
expect(readPendingSendCache({ paneKey: `tab-${CAP + 4}:leaf`, agent: 'claude' })).toHaveLength(
1
)
})
})
@@ -5,6 +5,7 @@
import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
/** An optimistic, not-yet-confirmed composer send. */
@@ -44,7 +45,10 @@ export function writePendingSendCache(
if (next.length === 0) {
pendingSendCache.delete(key)
} else {
pendingSendCache.set(key, next)
// Why: the empty-drain path above clears keys on the normal confirm flow,
// but a pane closed with an unconfirmed send (agent crash / early close)
// would strand its entry forever. LRU-bound the key count too.
setBoundedScopeCacheEntry(pendingSendCache, key, next)
}
return [...next]
}
@@ -233,7 +237,11 @@ export function appendCommandMarkerCache(
...(commandMarkerCache.get(key) ?? []),
{ id: `${sentAt}-${commandMarkerCounter}`, command, sentAt }
].slice(-COMMAND_MARKER_LIMIT)
commandMarkerCache.set(key, next)
// Why: the per-key array is capped at 8, but the KEY (paneKey\0agent\0sessionId,
// sessionId changes on every /clear) is ephemeral and was never evicted, so it
// grew one entry per (pane, session) for the renderer's whole life. LRU-bound
// the key count (mirrors the #7566 draft/attachment caches in this folder).
setBoundedScopeCacheEntry(commandMarkerCache, key, next)
return [...next]
}
@@ -7,6 +7,7 @@ import {
type NativeChatResolvedTarget
} from './native-chat-composer-target'
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
export type UseNativeChatComposerAttachmentsArgs = {
attachmentScopeKey: string
@@ -153,7 +154,8 @@ function writeNativeChatAttachmentCache(
attachmentCache.delete(scopeKey)
return
}
attachmentCache.set(scopeKey, [...attachments])
// LRU-bounded so pending attachments for permanently-removed panes can't accumulate.
setBoundedScopeCacheEntry(attachmentCache, scopeKey, [...attachments])
}
export function clearNativeChatAttachmentCacheForTests(): void {
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { useAppStore } from '../../store'
import type { AgentType } from '../../../../shared/agent-status-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/types'
import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import { canToggleNativeChat } from './native-chat-availability'
import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut'
@@ -72,8 +72,8 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
agentStatusByPaneKey: state.agentStatusByPaneKey
})
const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
? (resolveTabAgentFromTitle(tab.label ?? '') ??
(terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null))
? (resolveCommittedTitleAgentType(tab.label ?? '') ??
(terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null))
: null
if (
!canToggleNativeChat({
@@ -154,7 +154,10 @@ export function AiVaultSessionVirtualList({
})
return (
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div
ref={listScrollRef}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-sleek"
>
{loading && sessionsCount === 0 ? <SessionLoadingState /> : null}
{!loading && sessionsCount === 0 && !error ? (
@@ -250,6 +250,32 @@ describe('isMutablePRConversationComment', () => {
})
describe('PRCommentsList', () => {
it('pins the comments header while scrolling the sidebar', () => {
const comments: PRComment[] = [
{
id: 1,
author: 'AmethystLiang',
authorAvatarUrl: '',
body: 'Existing review context',
createdAt: '2026-05-14T00:00:00Z',
url: 'https://github.com/acme/widgets/pull/42#issuecomment-1'
}
]
const markup = renderWithTooltips(
React.createElement(PRCommentsList, {
comments,
commentsLoading: false,
onAddComment: () => Promise.resolve({ ok: true as const })
})
)
const commentsHeaderIndex = markup.indexOf('Comments')
const stickyIndex = markup.indexOf('sticky top-0 z-10 bg-sidebar/95 backdrop-blur-sm')
expect(stickyIndex).toBeGreaterThan(-1)
expect(stickyIndex).toBeLessThan(commentsHeaderIndex)
})
it('places the collapsed add-comment action in the comments header', () => {
const comments: PRComment[] = [
{
@@ -2358,7 +2358,14 @@ export function PRCommentsList({
return (
<div className="border-t border-border">
{/* Header */}
<div className={presentation.sectionHeader}>
<div
className={cn(
presentation.sectionHeader,
// Why: the checks sidebar scrolls as one column; pinning this header keeps
// filter and add-comment actions reachable while reading long threads.
'sticky top-0 z-10 bg-sidebar/95 backdrop-blur-sm'
)}
>
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="size-3.5 text-muted-foreground" />
<span className={presentation.sectionHeaderLabel}>
@@ -13,6 +13,11 @@ import { cn } from '@/lib/utils'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentCacheTimerSection } from './AgentCacheTimerSection'
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
import {
AgentSessionSourceHomeInput,
buildCodexSessionSourceHomeControl,
type AgentSessionSourceHomeControl
} from './codex-session-source-home-control'
import {
getAgentGeneratedTabTitlesDescription,
getAgentGeneratedTabTitlesTitle
@@ -81,6 +86,8 @@ type AgentRowProps = {
onSaveOverride: (value: string) => void
onSaveArgs: (value: string) => void
onSaveEnv: (value: Record<string, string>) => void
/** Codex-only: current runtime scope label + persisted history-source override. */
sessionSourceHome?: AgentSessionSourceHomeControl
}
type AgentCommandOverrideInputProps = {
@@ -277,42 +284,44 @@ function AgentCommandOverrideInput({
}
return (
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.2e45ca29b6', 'Command')}
</span>
<Input
value={cmdDraft}
onChange={(e) => setCmdDraft(e.target.value)}
onBlur={commitCmd}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitCmd()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setCmdDraft(draftSeed)
e.currentTarget.blur()
}
}}
placeholder={defaultCmd}
spellCheck={false}
className="h-7 flex-1 font-mono text-xs"
/>
{cmdOverride && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveOverride('')
setCmdDraft(defaultCmd)
<div className="flex items-center gap-2">
<Input
value={cmdDraft}
onChange={(e) => setCmdDraft(e.target.value)}
onBlur={commitCmd}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitCmd()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setCmdDraft(draftSeed)
e.currentTarget.blur()
}
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
placeholder={defaultCmd}
spellCheck={false}
className="h-7 flex-1 font-mono text-xs"
/>
{cmdOverride && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveOverride('')
setCmdDraft(defaultCmd)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
</div>
</div>
)
}
@@ -330,45 +339,47 @@ function AgentDefaultArgsInput({
}
return (
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.cfb3f35775', 'Arguments')}
</span>
<Input
value={argsDraft}
onChange={(e) => setArgsDraft(e.target.value)}
onBlur={commitArgs}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitArgs()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setArgsDraft(draftSeed)
e.currentTarget.blur()
}
}}
placeholder={
defaultArgs ||
translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
}
spellCheck={false}
className="h-7 flex-1 font-mono text-xs"
/>
{argsOverride !== defaultArgs && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveArgs(defaultArgs)
setArgsDraft(defaultArgs)
<div className="flex items-center gap-2">
<Input
value={argsDraft}
onChange={(e) => setArgsDraft(e.target.value)}
onBlur={commitArgs}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitArgs()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setArgsDraft(draftSeed)
e.currentTarget.blur()
}
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
placeholder={
defaultArgs ||
translate('auto.components.settings.AgentsPane.6f99bf5dd0', 'No default arguments')
}
spellCheck={false}
className="h-7 flex-1 font-mono text-xs"
/>
{argsOverride !== defaultArgs && (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => {
onSaveArgs(defaultArgs)
setArgsDraft(defaultArgs)
}}
className="h-7 shrink-0 text-xs text-muted-foreground hover:text-foreground"
>
{translate('auto.components.settings.AgentsPane.5200dac9da', 'Reset')}
</Button>
)}
</div>
</div>
)
}
@@ -394,11 +405,11 @@ function AgentDefaultEnvInput({
}
return (
<div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')}
</span>
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
{translate('auto.components.settings.AgentsPane.8fbe1f37c1', 'Environment')}
</span>
<Input
value={envDraft}
onChange={(e) => {
@@ -476,7 +487,8 @@ function AgentRow({
onSetEnabled,
onSaveOverride,
onSaveArgs,
onSaveEnv
onSaveEnv,
sessionSourceHome
}: AgentRowProps): React.JSX.Element {
const envSummary = stringifyAgentDefaultEnvDraft(envOverride)
const defaultEnvSummary = stringifyAgentDefaultEnvDraft(defaultEnv)
@@ -494,15 +506,6 @@ function AgentRow({
<div className="min-w-0 flex-1 sm:min-w-[12rem]">
<div className="flex items-center gap-2">
<span className="text-sm font-medium leading-none">{label}</span>
{isDetected ? (
<SettingsBadge tone="accent">
{translate('auto.components.settings.AgentsPane.c8794e622e', 'Detected')}
</SettingsBadge>
) : (
<SettingsBadge tone="muted">
{translate('auto.components.settings.AgentsPane.df123171d1', 'Not installed')}
</SettingsBadge>
)}
{!isEnabled && (
<SettingsBadge tone="muted">
{translate('auto.components.settings.AgentsPane.8dc0192e48', 'Disabled')}
@@ -523,7 +526,7 @@ function AgentRow({
</div>
</div>
<div className="ml-auto grid shrink-0 grid-cols-[max-content_6.5rem_1.75rem_1.75rem_1.75rem] items-center gap-1.5">
<div className="ml-auto grid shrink-0 grid-cols-[max-content_6.5rem_1.75rem_1.75rem] items-center gap-1.5">
<AgentAvailabilityControl
label={label}
isEnabled={isEnabled}
@@ -552,28 +555,6 @@ function AgentRow({
)}
</div>
<div className="flex size-7 items-center justify-center">
{isDetected && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setCmdOpen((prev) => !prev)}
title={translate(
'auto.components.settings.AgentsPane.db9e9e5887',
'Customize command'
)}
aria-expanded={cmdOpen}
className={cn(
'size-7 text-muted-foreground hover:text-foreground',
(cmdOpen || cmdOverride) && 'text-foreground'
)}
>
<Terminal className="size-3.5" />
</Button>
)}
</div>
<a
href={homepageUrl}
target="_blank"
@@ -644,7 +625,17 @@ function AgentRow({
/>
</div>
)}
<p className="mt-1.5 text-[11px] text-muted-foreground">
{sessionSourceHome && (
<div className="mt-2">
<AgentSessionSourceHomeInput
key={`${agentId}:${sessionSourceHome.runtimeLabel}:${sessionSourceHome.value}`}
runtimeLabel={sessionSourceHome.runtimeLabel}
value={sessionSourceHome.value}
onSave={sessionSourceHome.onSave}
/>
</div>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.AgentsPane.f9f127d664',
'Override the binary path or name, and edit the default launch arguments or environment for this agent.'
@@ -902,6 +893,11 @@ export function AgentsPane({
onSaveOverride={(v) => saveOverride(agent.id, v)}
onSaveArgs={(v) => saveAgentArgs(agent.id, v)}
onSaveEnv={(v) => saveAgentEnv(agent.id, v)}
sessionSourceHome={
agent.id === 'codex'
? buildCodexSessionSourceHomeControl(settings, updateSettings)
: undefined
}
/>
))}
</div>
@@ -38,6 +38,7 @@ type AppearanceInterfaceSectionProps = {
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
isDesktopWindows: boolean
onRequestFontSuggestions?: () => void
forceVisiblePrimary?: boolean
}
@@ -47,6 +48,7 @@ export function AppearanceInterfaceSection({
applyTheme,
fontSuggestions,
isDesktopWindows,
onRequestFontSuggestions,
forceVisiblePrimary = false
}: AppearanceInterfaceSectionProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
@@ -142,6 +144,7 @@ export function AppearanceInterfaceSection({
value={settings.appFontFamily}
suggestions={fontSuggestions}
placeholder={DEFAULT_APP_FONT_FAMILY}
onRequestSuggestions={onRequestFontSuggestions}
onChange={(value) =>
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
}
@@ -129,7 +129,10 @@ function createWarpThemesStub() {
async function renderAppearancePane(
settings: GlobalSettings,
updateSettings: (updates: Partial<GlobalSettings>) => void = vi.fn()
updateSettings: (updates: Partial<GlobalSettings>) => void = vi.fn(),
options: {
onRequestFontSuggestions?: () => void
} = {}
): Promise<HTMLDivElement> {
const container = document.createElement('div')
document.body.appendChild(container)
@@ -146,6 +149,7 @@ async function renderAppearancePane(
applyTheme={vi.fn()}
fontSuggestions={[]}
terminalFontSuggestions={[]}
onRequestFontSuggestions={options.onRequestFontSuggestions}
systemPrefersDark={false}
ghostty={createGhosttyStub() as never}
warpThemes={createWarpThemesStub() as never}
@@ -296,6 +300,25 @@ describe('AppearancePane', () => {
expect(container.textContent).not.toContain('Code & Markdown')
})
it('requests installed font suggestions only after the IDE font picker is used', async () => {
mocks.state.settingsSearchQuery = ''
const requestFontSuggestions = vi.fn()
const container = await renderAppearancePane(getDefaultSettings('/tmp'), vi.fn(), {
onRequestFontSuggestions: requestFontSuggestions
})
expect(requestFontSuggestions).not.toHaveBeenCalled()
const input = container.querySelector<HTMLInputElement>('input[role="combobox"]')
expect(input).not.toBeNull()
await act(async () => {
input?.focus()
})
expect(requestFontSuggestions).toHaveBeenCalledOnce()
})
it('keeps the app icon control at the bottom of the pane, after the section rows', async () => {
mocks.state.settingsSearchQuery = ''
const container = await renderAppearancePane(getDefaultSettings('/tmp'))
@@ -45,6 +45,7 @@ type AppearancePaneProps = {
applyTheme: (theme: 'system' | 'dark' | 'light') => void
fontSuggestions: string[]
terminalFontSuggestions: string[]
onRequestFontSuggestions?: () => void
systemPrefersDark: boolean
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
@@ -68,6 +69,7 @@ export function AppearancePane({
applyTheme,
fontSuggestions,
terminalFontSuggestions,
onRequestFontSuggestions,
systemPrefersDark,
ghostty,
warpThemes
@@ -179,6 +181,7 @@ export function AppearancePane({
updateSettings={updateSettings}
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
onRequestFontSuggestions={onRequestFontSuggestions}
isDesktopWindows={isDesktopWindows}
forceVisiblePrimary={interfaceLabelMatches}
/>
@@ -205,6 +208,7 @@ export function AppearancePane({
updateSettings={updateSettings}
systemPrefersDark={systemPrefersDark}
terminalFontSuggestions={terminalFontSuggestions}
onRequestFontSuggestions={onRequestFontSuggestions}
ghostty={ghostty}
warpThemes={warpThemes}
forceVisiblePrimary={terminalLabelMatches}
@@ -23,7 +23,11 @@ import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-pref
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { applyDocumentTheme } from '@/lib/document-theme'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { SCROLLBACK_PRESETS_ROWS, getFallbackTerminalFonts } from './SettingsConstants'
import {
SCROLLBACK_PRESETS_ROWS,
getFallbackTerminalFonts,
mergeFontSuggestions
} from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { GeneralPane } from './GeneralPane'
@@ -313,7 +317,7 @@ function Settings(): React.JSX.Element {
const ghostty = useGhosttyImport(updateSettings, settings)
const warpThemes = useWarpThemeImport(updateSettings, settings)
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
mergeFontSuggestions([], getFallbackTerminalFonts())
)
const terminalFontSuggestions = useMemo(
() => fontSuggestions.filter((font) => font !== DEFAULT_APP_FONT_FAMILY),
@@ -336,7 +340,9 @@ function Settings(): React.JSX.Element {
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
const contentScrollRef = useRef<HTMLDivElement | null>(null)
const searchInputRef = useRef<HTMLInputElement | null>(null)
const terminalFontsLoadedRef = useRef(false)
const installedFontsLoadedRef = useRef(false)
const installedFontsLoadPromiseRef = useRef<Promise<void> | null>(null)
const settingsMountedRef = useRef(true)
const pendingNavSectionRef = useRef<string | null>(null)
const pendingScrollTargetRef = useRef<string | null>(null)
const pendingSubsectionScrollFrameRef = useRef<number | null>(null)
@@ -394,6 +400,42 @@ function Settings(): React.JSX.Element {
cancelPendingSettingsSubsectionScrollFrame(pendingSubsectionScrollFrameRef)
}, [])
useEffect(() => {
// Why: React dev StrictMode replays mount effects; async font requests
// should still commit while the Settings view is actually mounted.
settingsMountedRef.current = true
return () => {
settingsMountedRef.current = false
}
}, [])
const requestFontSuggestions = useCallback((): void => {
if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) {
return
}
installedFontsLoadPromiseRef.current = window.api.settings
.listFonts()
.then((fonts) => {
if (!settingsMountedRef.current) {
return
}
// Latch after the first successful attempt even when empty, so a font-less
// system doesn't reissue listFonts() on every picker interaction.
installedFontsLoadedRef.current = true
if (fonts.length === 0) {
return
}
setFontSuggestions((prev) => mergeFontSuggestions(fonts, prev))
})
.catch(() => {
// Fall back to curated cross-platform suggestions.
})
.finally(() => {
installedFontsLoadPromiseRef.current = null
})
}, [])
// Pure "discard and leave?" prompt — no side effects. Why separate from the
// discard helper below: the window-close guard must ask without clearing the
// drafts, since a later guard/handler can still cancel the close.
@@ -754,35 +796,6 @@ function Settings(): React.JSX.Element {
setMountedSectionIds(neededSectionIds)
}
useEffect(() => {
if (!neededSectionIds.has('appearance') && !neededSectionIds.has('terminal')) {
return
}
if (terminalFontsLoadedRef.current) {
return
}
let stale = false
const loadFontSuggestions = async (): Promise<void> => {
try {
const fonts = await window.api.settings.listFonts()
if (stale || fonts.length === 0) {
return
}
terminalFontsLoadedRef.current = true
setFontSuggestions((prev) =>
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320)
)
} catch {
// Fall back to curated cross-platform suggestions.
}
}
void loadFontSuggestions()
return () => {
stale = true
}
}, [neededSectionIds])
const neededRepoIds = useMemo(
() => deriveNeededRepoIds(repos, neededSectionIds),
[neededSectionIds, repos]
@@ -1410,6 +1423,7 @@ function Settings(): React.JSX.Element {
applyTheme={applyTheme}
fontSuggestions={fontSuggestions}
terminalFontSuggestions={terminalFontSuggestions}
onRequestFontSuggestions={requestFontSuggestions}
systemPrefersDark={systemPrefersDark}
ghostty={ghostty}
warpThemes={warpThemes}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
import { mergeFontSuggestions } from './SettingsConstants'
describe('mergeFontSuggestions', () => {
it('keeps installed fonts beyond the old hydration cutoff', () => {
const systemFonts = Array.from({ length: 350 }, (_value, index) => `System Font ${index}`)
const suggestions = mergeFontSuggestions(systemFonts, ['JetBrains Mono'])
expect(suggestions).toContain(DEFAULT_APP_FONT_FAMILY)
expect(suggestions).toContain('System Font 349')
expect(suggestions).toContain('JetBrains Mono')
})
})
@@ -1,5 +1,5 @@
import type { OrcaHooks } from '../../../../shared/types'
import { getDefaultRepoHookSettings } from '../../../../shared/constants'
import { DEFAULT_APP_FONT_FAMILY, getDefaultRepoHookSettings } from '../../../../shared/constants'
import { DESKTOP_TERMINAL_SCROLLBACK_ROW_PRESETS } from '../../../../shared/terminal-scrollback-policy'
export type HookName = keyof OrcaHooks['scripts']
@@ -14,6 +14,15 @@ export function zoomLevelToPercent(level: number): number {
return Math.round(100 * Math.pow(1.2, level))
}
export function mergeFontSuggestions(
systemFonts: readonly string[],
previousFonts: readonly string[]
): string[] {
// Why: picker rendering can be capped later, but the source list must keep
// every installed font searchable/selectable.
return Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...systemFonts, ...previousFonts]))
}
export function getFallbackTerminalFonts(): string[] {
const nav =
typeof navigator !== 'undefined'
@@ -4,6 +4,7 @@ import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FontAutocomplete } from './SettingsFormControls'
import { FONT_SUGGESTION_RENDER_LIMIT } from './settings-form-option-filter'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, defaultValue: string) => defaultValue
@@ -186,4 +187,85 @@ describe('FontAutocomplete', () => {
expect(getOptionLabels()).toEqual(['JetBrains Mono'])
})
it('requests installed suggestions only after the picker is used', async () => {
const requestSuggestions = vi.fn()
function Harness(): ReactNode {
const [value, setValue] = useState('Geist')
return (
<FontAutocomplete
value={value}
suggestions={['Geist', 'JetBrains Mono']}
onChange={setValue}
onRequestSuggestions={requestSuggestions}
/>
)
}
await act(async () => {
root.render(<Harness />)
})
expect(requestSuggestions).not.toHaveBeenCalled()
await act(async () => {
getInput().focus()
})
expect(requestSuggestions).toHaveBeenCalledOnce()
})
it('searches suggestions past the previous 320 font cutoff', async () => {
const suggestions = [
...Array.from({ length: 350 }, (_value, index) => `System Font ${index}`),
'Zed Mono'
]
function Harness(): ReactNode {
const [value, setValue] = useState('System Font 0')
return <FontAutocomplete value={value} suggestions={suggestions} onChange={setValue} />
}
await act(async () => {
root.render(<Harness />)
})
const input = getInput()
await act(async () => {
input.focus()
})
expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
expect(getOptionLabels()).not.toContain('Zed Mono')
await typeIntoInput(input, 'Zed')
expect(getOptionLabels()).toEqual(['Zed Mono'])
})
it('renders the selected late-list font even when the full list is capped', async () => {
const suggestions = [
...Array.from({ length: FONT_SUGGESTION_RENDER_LIMIT + 30 }, (_value, index) => {
return `System Font ${index}`
}),
'Zed Mono'
]
function Harness(): ReactNode {
const [value, setValue] = useState('Zed Mono')
return <FontAutocomplete value={value} suggestions={suggestions} onChange={setValue} />
}
await act(async () => {
root.render(<Harness />)
})
await act(async () => {
getInput().focus()
})
expect(getOptionLabels()).toHaveLength(FONT_SUGGESTION_RENDER_LIMIT)
expect(getOptionLabels().at(-1)).toBe('Zed Mono')
})
})
@@ -13,6 +13,7 @@ import { MAX_THEME_RESULTS } from './SettingsConstants'
import {
filterFontSuggestions,
filterTerminalThemeOptions,
getRenderedFontSuggestions,
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
import { cn } from '@/lib/utils'
@@ -286,6 +287,7 @@ type FontAutocompleteProps = {
suggestions: string[]
onChange: (value: string) => void
placeholder?: string
onRequestSuggestions?: () => void
/** Fires with whichever option the user is currently highlighting in the
* dropdown (via mouse hover or keyboard arrow), or null when nothing is
* highlighted / the dropdown is closed. Lets a consumer show a live
@@ -588,6 +590,7 @@ export function FontAutocomplete({
suggestions,
onChange,
placeholder = 'SF Mono',
onRequestSuggestions,
onPreviewFontFamily
}: FontAutocompleteProps): React.JSX.Element {
const [query, setQuery] = useState(value)
@@ -619,8 +622,15 @@ export function FontAutocomplete({
}
}
const requestSuggestions = useCallback((): void => {
onRequestSuggestions?.()
}, [onRequestSuggestions])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
if (nextOpen) {
requestSuggestions()
}
if (!nextOpen) {
setIsFilteringQuery(false)
}
@@ -636,6 +646,10 @@ export function FontAutocomplete({
// still reveal every installed font instead of only fonts sharing that name.
const visibleSuggestions =
!isFilteringQuery && normalizedQuery === normalizedValue ? suggestions : filteredSuggestions
const renderedSuggestions = useMemo(
() => getRenderedFontSuggestions(visibleSuggestions, highlightedIndex),
[visibleSuggestions, highlightedIndex]
)
// Why: sync the highlighted index during render rather than via useEffect so
// the correct item is highlighted on the very first paint after open/filter
@@ -699,12 +713,14 @@ export function FontAutocomplete({
value={query}
onChange={(e) => {
const next = e.target.value
requestSuggestions()
setQuery(next)
setIsFilteringQuery(true)
onChange(next)
setOpen(true)
}}
onFocus={() => {
requestSuggestions()
setIsFilteringQuery(false)
setOpen(true)
}}
@@ -795,6 +811,7 @@ export function FontAutocomplete({
setIsFilteringQuery(false)
}
if (nextOpen) {
requestSuggestions()
focusInput()
}
}}
@@ -831,29 +848,29 @@ export function FontAutocomplete({
}}
>
<ScrollArea
className={visibleSuggestions.length > 8 ? 'h-64' : undefined}
className={renderedSuggestions.length > 8 ? 'h-64' : undefined}
style={popoverAvailableHeightStyle}
viewportProps={{ style: popoverAvailableHeightStyle }}
>
<div id={listboxId} role="listbox" className="p-1">
{visibleSuggestions.length > 0 ? (
visibleSuggestions.map((font, index) => (
renderedSuggestions.map(({ font, sourceIndex }) => (
<button
key={font}
type="button"
id={`${listboxId}-option-${index}`}
id={`${listboxId}-option-${sourceIndex}`}
role="option"
aria-selected={index === highlightedIndex}
aria-selected={sourceIndex === highlightedIndex}
ref={(element) => {
if (element && index === highlightedIndex) {
if (element && sourceIndex === highlightedIndex) {
element.scrollIntoView({ block: 'nearest' })
}
}}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseEnter={() => setHighlightedIndex(sourceIndex)}
onClick={() => commitValue(font)}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
index === highlightedIndex
sourceIndex === highlightedIndex
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60'
}`}
@@ -254,7 +254,10 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element {
const handleImport = async (): Promise<void> => {
try {
const synced = (await window.api.ssh.importConfig()) as SshTarget[]
// Why: the explicit Import action re-adopts every ~/.ssh/config host,
// including ones the user previously deleted — clear tombstones so a
// deliberate re-import can bring them back.
const synced = (await window.api.ssh.importConfig({ reAdopt: true })) as SshTarget[]
recordFeatureInteraction('ssh')
if (mountedRef.current) {
if (synced.length === 0) {
@@ -44,6 +44,7 @@ type TerminalAppearanceSectionProps = {
updateSettings: (updates: Partial<GlobalSettings>) => void
systemPrefersDark: boolean
terminalFontSuggestions: string[]
onRequestFontSuggestions?: () => void
ghostty: UseGhosttyImportReturn
warpThemes: UseWarpThemeImportReturn
forceVisiblePrimary?: boolean
@@ -74,6 +75,7 @@ export function TerminalAppearanceSection({
updateSettings,
systemPrefersDark,
terminalFontSuggestions,
onRequestFontSuggestions,
ghostty,
warpThemes,
forceVisiblePrimary = false
@@ -225,6 +227,7 @@ export function TerminalAppearanceSection({
<FontAutocomplete
value={settings.terminalFontFamily}
suggestions={terminalFontSuggestions}
onRequestSuggestions={onRequestFontSuggestions}
onChange={(value) => updateSettings({ terminalFontFamily: value })}
onPreviewFontFamily={setPreviewFontFamily}
/>
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { buildCodexSessionSourceHomeControl } from './codex-session-source-home-control'
function settingsWith(
overrides: Partial<Pick<GlobalSettings, 'codexSessionSourceHome' | 'localWindowsRuntimeDefault'>>
): Pick<GlobalSettings, 'codexSessionSourceHome' | 'localWindowsRuntimeDefault'> {
return {
codexSessionSourceHome: overrides.codexSessionSourceHome,
localWindowsRuntimeDefault: overrides.localWindowsRuntimeDefault ?? { kind: 'windows-host' }
}
}
describe('buildCodexSessionSourceHomeControl', () => {
it('reads and writes the host override on the host runtime', () => {
const updateSettings = vi.fn()
const control = buildCodexSessionSourceHomeControl(
settingsWith({ codexSessionSourceHome: { host: '/custom/codex' } }),
updateSettings
)
expect(control.runtimeLabel).toBe('~/.codex')
expect(control.value).toBe('/custom/codex')
control.onSave('/new/codex')
expect(updateSettings).toHaveBeenCalledWith({
codexSessionSourceHome: { host: '/new/codex' }
})
})
it('scopes to the selected WSL distro', () => {
const updateSettings = vi.fn()
const control = buildCodexSessionSourceHomeControl(
settingsWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }
}),
updateSettings
)
expect(control.value).toBe('/home/me/.codex')
control.onSave('/home/me/.config/codex')
expect(updateSettings).toHaveBeenCalledWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
})
})
it('reads a stored WSL value when the reported distro casing differs', () => {
// Stored as "Ubuntu"; the runtime reports "ubuntu". The launch-time resolver
// matches case-insensitively, so the UI must surface the active value too.
const control = buildCodexSessionSourceHomeControl(
settingsWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
}),
vi.fn()
)
expect(control.value).toBe('/home/me/.codex')
})
it('updates the existing WSL key in place instead of creating a duplicate casing', () => {
const updateSettings = vi.fn()
const control = buildCodexSessionSourceHomeControl(
settingsWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
}),
updateSettings
)
control.onSave('/home/me/.config/codex')
// No stray "ubuntu" key alongside "Ubuntu".
expect(updateSettings).toHaveBeenCalledWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } }
})
})
it('clears the matching WSL key regardless of casing when reset', () => {
const updateSettings = vi.fn()
const control = buildCodexSessionSourceHomeControl(
settingsWith({
codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.codex' } },
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'ubuntu' }
}),
updateSettings
)
control.onSave('')
// Removing the only key leaves no dangling wsl object.
expect(updateSettings).toHaveBeenCalledWith({
codexSessionSourceHome: { wsl: undefined }
})
})
it('falls back to the host control when a WSL scope has no selected distro', () => {
const control = buildCodexSessionSourceHomeControl(
settingsWith({
codexSessionSourceHome: { host: '/custom/codex' },
localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
}),
vi.fn()
)
expect(control.runtimeLabel).toBe('~/.codex')
expect(control.value).toBe('/custom/codex')
})
})

Some files were not shown because too many files have changed in this diff Show More