Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/main/daemon/headless-emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ describe('HeadlessEmulator', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
expect(emulator.getSnapshot().cwd).toBeNull()
})

it('honors defaultScrollback when explicit scrollback is omitted', () => {
// Why: `orca serve` passes a smaller default to cut per-PTY memory; explicit scrollback
// (used by snapshot/hydration paths) must still take precedence.
emulator = new HeadlessEmulator({ cols: 80, rows: 5, defaultScrollback: 3 })
// The terminal instance carries the effective scrollback via its options.
expect(
(emulator as unknown as { terminal: { options: { scrollback: number } } }).terminal.options
.scrollback
).toBe(3)
})

it('prefers explicit scrollback over defaultScrollback', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 5, defaultScrollback: 1, scrollback: 10 })
// Why: explicit scrollback wins; the defaultScrollback value is ignored.
expect(
(emulator as unknown as { terminal: { options: { scrollback: number } } }).terminal.options
.scrollback
).toBe(10)
})

it('falls back to the module default when neither option is set', () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 5 })
// Why: module default is 5000; without options we get the desktop-class buffer.
expect(
(emulator as unknown as { terminal: { options: { scrollback: number } } }).terminal.options
.scrollback
).toBe(5000)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

describe('write and snapshot', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/main/daemon/headless-emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
/**
* Override the default scrollback when `scrollback` is not provided. The default is 5000, but
* long-lived `orca serve` deployments allocate one HeadlessEmulator per PTY and the scrollback
* dominates per-PTY memory (~1-3 MB each). Serve callers can pass a smaller value (e.g. 1000)
* to trade retained history for lower RSS; mobile clients can re-pull via `terminal-snapshot`.
*/
defaultScrollback?: number
/** Query reply sink (terminal-query-authority.md); only `forwardQueryReplies` writes emit here. The daemon Session must never pass this. */
onQueryReply?: (reply: string) => void
pathFlavor?: 'posix' | 'win32'
Expand Down Expand Up @@ -80,7 +87,7 @@ export class HeadlessEmulator {
this.terminal = new Terminal({
cols: opts.cols,
rows: opts.rows,
scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK,
scrollback: opts.scrollback ?? opts.defaultScrollback ?? DEFAULT_SCROLLBACK,
allowProposedApi: true,
logLevel: 'off',
// Why: parse CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app pushed (renderer parity).
Expand Down
68 changes: 68 additions & 0 deletions src/main/git/status-clear-caches-for-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearGitReadCachesForPaths,
clearSubmodulePathsCacheForTests,
clearEffectiveUpstreamStatusCacheForTests,
primeGitReadCachesForTests,
getSubmodulePathsCacheCountForTests
} from './status'

// Why: helper for repo-removal teardown; the matching logic is exercised here by priming the
// module-level caches with predictable entries and asserting that only matching keys are removed.
describe('clearGitReadCachesForPaths', () => {
beforeEach(() => {
clearSubmodulePathsCacheForTests()
clearEffectiveUpstreamStatusCacheForTests()
})

it('is a safe no-op for empty input', () => {
expect(() => clearGitReadCachesForPaths([])).not.toThrow()
expect(getSubmodulePathsCacheCountForTests()).toBe(0)
})

it('tolerates paths with no matching cache entries', () => {
primeGitReadCachesForTests(['/worktrees/repo-a'])
expect(() => clearGitReadCachesForPaths(['/no/such/path', '/another/missing'])).not.toThrow()
expect(getSubmodulePathsCacheCountForTests()).toBe(1)
})

it('removes entries whose worktree path matches the prefix', () => {
primeGitReadCachesForTests(['/worktrees/repo-a', '/worktrees/repo-a/sub', '/worktrees/repo-b'])
expect(getSubmodulePathsCacheCountForTests()).toBe(3)

clearGitReadCachesForPaths(['/worktrees/repo-a'])

// repo-a and repo-a/sub are removed by the prefix match; repo-b is preserved.
expect(getSubmodulePathsCacheCountForTests()).toBe(1)
})

it('removes entries across all three git caches', () => {
primeGitReadCachesForTests(['/worktrees/repo-a', '/worktrees/repo-b'])

clearGitReadCachesForPaths(['/worktrees/repo-a'])

expect(getSubmodulePathsCacheCountForTests()).toBe(1)
})

it('handles multiple paths in one call', () => {
primeGitReadCachesForTests(['/worktrees/repo-a', '/worktrees/repo-b', '/worktrees/repo-c'])

clearGitReadCachesForPaths(['/worktrees/repo-a', '/worktrees/repo-c'])

expect(getSubmodulePathsCacheCountForTests()).toBe(1)
})

// Why: a naive startsWith would also clear `/worktrees/repo-ab` and `/worktrees/repo-a/sub`
// when clearing `/worktrees/repo-a`. The boundary check (exact match OR path-separator
// boundary) must reject the sibling (`/worktrees/repo-ab`) and keep the descendant
// (`/worktrees/repo-a/sub`).
it('distinguishes siblings from descendants when clearing by path', () => {
primeGitReadCachesForTests(['/worktrees/repo-a', '/worktrees/repo-ab', '/worktrees/repo-a/sub'])

clearGitReadCachesForPaths(['/worktrees/repo-a'])

// /worktrees/repo-a (exact) and /worktrees/repo-a/sub (descendant) removed;
// /worktrees/repo-ab (sibling) preserved.
expect(getSubmodulePathsCacheCountForTests()).toBe(1)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
76 changes: 76 additions & 0 deletions src/main/git/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,55 @@ export function invalidateGitReadCaches(): void {
resolvedUpstreamNameCache.clear()
}

/**
* Drop every cache entry whose worktree path matches one of the given paths (substring match
* against the cache key, which is `[worktreePath, ...runtimeOptions].join('\0')`). WSL and SSH
* runtimes only key on `wslDistro` / `connectionId`, so the path portion is always present.
* Cheap relative to a full cache clear; intended for repo-removal teardown so stale entries do
* not linger up to SUBMODULE_PATHS_CACHE_TTL_MS / RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS.
*/
export function clearGitReadCachesForPaths(paths: readonly string[]): void {
if (paths.length === 0) {
return
}
const matches = (key: string): boolean => {
// Why: cache keys are `[path, wslDistro].join('\0')`. A naive startsWith matches
// siblings (`/worktrees/repo-a` would also clear `/worktrees/repo-ab`). Compare the
// path component up to the first `\0`, then require either an exact match or a path
// separator so descendants match and siblings do not.
const separatorIdx = key.indexOf('\0')
const keyPath = separatorIdx === -1 ? key : key.slice(0, separatorIdx)
for (const path of paths) {
if (keyPath === path) {
return true
}
if (
keyPath.length > path.length &&
(keyPath[path.length] === '/' || keyPath[path.length] === '\\') &&
keyPath.startsWith(path)
) {
return true
}
}
return false
}
for (const [key] of submodulePathsCache) {
if (matches(key)) {
submodulePathsCache.delete(key)
}
}
for (const [key] of resolvedUpstreamNameCache) {
if (matches(key)) {
resolvedUpstreamNameCache.delete(key)
}
}
for (const [key] of effectiveUpstreamStatusCache) {
if (matches(key)) {
effectiveUpstreamStatusCache.delete(key)
}
}
}

export async function runWithGitReadCacheInvalidation<T>(run: () => Promise<T>): Promise<T> {
invalidateGitReadCaches()
try {
Expand All @@ -137,6 +186,33 @@ export function getSubmodulePathsCacheCountForTests(): number {
return submodulePathsCache.size
}

/**
* Test-only: prime the submodule, resolved-upstream-name, and effective-upstream-status caches
* with the given worktree-path keys. Returns the counts so callers can assert eviction behavior.
* Keys use the same `[path, wslDistro].join('\0')` shape as production writes.
*/
export function primeGitReadCachesForTests(
paths: readonly string[],
wslDistro: string | null = null
): { submodule: number; upstreamName: number; effectiveStatus: number } {
for (const path of paths) {
const key = [path, wslDistro ?? null].join('\0')
const farFuture = Date.now() + 60 * 60 * 1000
submodulePathsCache.set(key, { paths: [], expiresAt: farFuture })
resolvedUpstreamNameCache.set(key, { upstreamName: 'origin', expiresAt: farFuture })
effectiveUpstreamStatusCache.set(key, {
status: { kind: 'unset' },
expiresAt: farFuture,
writeGeneration: 0
} as unknown as EffectiveUpstreamStatusCacheEntry)
}
return {
submodule: submodulePathsCache.size,
upstreamName: resolvedUpstreamNameCache.size,
effectiveStatus: effectiveUpstreamStatusCache.size
}
}

function gitRuntimeOptionsKey(options: GitRuntimeOptions): readonly unknown[] {
return [options.wslDistro ?? null]
}
Expand Down
Loading