Fix local preflight context cache leak (#7669)

This commit is contained in:
Neil
2026-07-11 01:26:53 -07:00
committed by GitHub
parent a5486b1351
commit 2ddb976f8a
3 changed files with 200 additions and 57 deletions
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import {
_getProjectRuntimePreflightContextCacheSizeForTest,
_getWslPreflightContextCacheSizeForTest,
_hasProjectRuntimePreflightContextCacheEntryForTest,
_hasWslPreflightContextCacheEntryForTest,
getLocalPreflightContext,
resetLocalPreflightContextCachesForTests
} from './local-preflight-context'
const WSL_CACHE_LIMIT = 128
const PROJECT_RUNTIME_CACHE_LIMIT = 2048
afterEach(() => {
resetLocalPreflightContextCachesForTests()
})
function makeWslState(distro: string): AppState {
return {
activeRepoId: 'repo-1',
activeWorktreeId: null,
repos: [{ id: 'repo-1', path: `\\\\wsl.localhost\\${distro}\\home\\alice\\repo` }],
worktreesByRepo: {}
} as AppState
}
function makeWindowsProjectState(projectId: string): AppState {
return {
activeRepoId: projectId,
activeWorktreeId: null,
repos: [{ id: projectId, path: `C:\\Users\\alice\\${projectId}` }],
settings: {},
worktreesByRepo: {}
} as AppState
}
describe('local preflight context caches', () => {
it('releases an old WSL selector snapshot after sustained context churn', () => {
const first = getLocalPreflightContext(makeWslState('Distro0'), 'darwin')
for (let index = 1; index <= WSL_CACHE_LIMIT; index += 1) {
getLocalPreflightContext(makeWslState(`Distro${index}`), 'darwin')
}
expect(getLocalPreflightContext(makeWslState('Distro0'), 'darwin')).not.toBe(first)
})
it('caps cached WSL distro snapshots', () => {
for (let index = 0; index < WSL_CACHE_LIMIT + 1; index++) {
const distro = `Distro${index}`
expect(getLocalPreflightContext(makeWslState(distro), 'darwin')).toEqual({
wslDistro: distro
})
}
expect(_getWslPreflightContextCacheSizeForTest()).toBe(WSL_CACHE_LIMIT)
expect(_hasWslPreflightContextCacheEntryForTest('Distro0')).toBe(false)
expect(_hasWslPreflightContextCacheEntryForTest(`Distro${WSL_CACHE_LIMIT}`)).toBe(true)
})
it('caps cached project runtime snapshots without mutating order on reads', () => {
for (let index = 0; index < PROJECT_RUNTIME_CACHE_LIMIT; index++) {
getLocalPreflightContext(makeWindowsProjectState(`project-${index}`), 'win32')
}
getLocalPreflightContext(makeWindowsProjectState('project-0'), 'win32')
getLocalPreflightContext(
makeWindowsProjectState(`project-${PROJECT_RUNTIME_CACHE_LIMIT}`),
'win32'
)
expect(_getProjectRuntimePreflightContextCacheSizeForTest()).toBe(PROJECT_RUNTIME_CACHE_LIMIT)
expect(
_hasProjectRuntimePreflightContextCacheEntryForTest('project-0:windows-host:global-default')
).toBe(false)
expect(
_hasProjectRuntimePreflightContextCacheEntryForTest('project-1:windows-host:global-default')
).toBe(true)
})
})
@@ -0,0 +1,106 @@
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
export type LocalPreflightContext =
| {
wslDistro?: string | null
wslDefault?: boolean
runtimeContextKey?: string
projectRuntime?: ProjectExecutionRuntimeResolution
}
| undefined
// Why: selector snapshots must be reference-stable, but project/runtime ids can
// churn as repos are added and removed during a long-lived renderer session.
const WSL_PREFLIGHT_CONTEXT_CACHE_MAX = 128
const PROJECT_RUNTIME_PREFLIGHT_CONTEXT_CACHE_MAX = 2048
// Why: these reads run inside broad store selectors. Insertion-order eviction
// keeps cache hits read-only instead of adding Map mutations to every store update.
const wslPreflightContextsByDistro = new Map<string, NonNullable<LocalPreflightContext>>()
const projectRuntimePreflightContextsByKey = new Map<string, NonNullable<LocalPreflightContext>>()
export function resetLocalPreflightContextCachesForTests(): void {
wslPreflightContextsByDistro.clear()
projectRuntimePreflightContextsByKey.clear()
}
export function _getWslPreflightContextCacheSizeForTest(): number {
return wslPreflightContextsByDistro.size
}
export function _hasWslPreflightContextCacheEntryForTest(wslDistro: string): boolean {
return wslPreflightContextsByDistro.has(wslDistro)
}
export function _getProjectRuntimePreflightContextCacheSizeForTest(): number {
return projectRuntimePreflightContextsByKey.size
}
export function _hasProjectRuntimePreflightContextCacheEntryForTest(cacheKey: string): boolean {
return projectRuntimePreflightContextsByKey.has(cacheKey)
}
export function getWslPreflightContext(wslDistro: string): NonNullable<LocalPreflightContext> {
const cached = wslPreflightContextsByDistro.get(wslDistro)
if (cached) {
return cached
}
// Why: React/Zustand selectors must return a cached snapshot. A fresh object
// here triggers a useSyncExternalStore loop when Settings observes WSL repos.
const context = Object.freeze({ wslDistro })
storeCacheEntry(wslPreflightContextsByDistro, wslDistro, context, WSL_PREFLIGHT_CONTEXT_CACHE_MAX)
return context
}
export function getProjectRuntimePreflightContext(
resolution: ProjectExecutionRuntimeResolution
): NonNullable<LocalPreflightContext> {
const cacheKey = getProjectRuntimeContextObjectCacheKey(resolution)
const cached = projectRuntimePreflightContextsByKey.get(cacheKey)
if (cached) {
return cached
}
const wslDistro =
resolution.status === 'resolved' && resolution.runtime.kind === 'wsl'
? resolution.runtime.distro
: undefined
// Why: selectors compare by reference; cache each resolved runtime context so
// adding projectRuntime does not reintroduce useSyncExternalStore churn.
const context = Object.freeze({
...(wslDistro ? { wslDistro } : {}),
projectRuntime: resolution
})
storeCacheEntry(
projectRuntimePreflightContextsByKey,
cacheKey,
context,
PROJECT_RUNTIME_PREFLIGHT_CONTEXT_CACHE_MAX
)
return context
}
function getProjectRuntimeContextObjectCacheKey(
resolution: ProjectExecutionRuntimeResolution
): string {
if (resolution.status === 'resolved') {
return `${resolution.runtime.cacheKey}:${resolution.runtime.reason}`
}
return `${resolution.repair.cacheKey}:${resolution.repair.source}`
}
function storeCacheEntry<T>(
cache: Map<string, T>,
key: string,
value: T,
maxEntries: number
): void {
cache.set(key, value)
if (cache.size > maxEntries) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
}
+13 -57
View File
@@ -13,8 +13,21 @@ import {
getCachedWindowsTerminalCapabilities,
hasCachedWindowsTerminalCapabilities
} from './windows-terminal-capabilities'
import {
getProjectRuntimePreflightContext,
getWslPreflightContext,
type LocalPreflightContext
} from './local-preflight-context-cache'
export { localPreflightContextKey } from './local-preflight-context-key'
export type { LocalPreflightContext } from './local-preflight-context-cache'
export {
_getProjectRuntimePreflightContextCacheSizeForTest,
_getWslPreflightContextCacheSizeForTest,
_hasProjectRuntimePreflightContextCacheEntryForTest,
_hasWslPreflightContextCacheEntryForTest,
resetLocalPreflightContextCachesForTests
} from './local-preflight-context-cache'
type LocalProjectRuntimeState = Pick<
AppState,
@@ -26,67 +39,10 @@ type LocalProjectRuntimeWslContext = {
availableWslDistros?: readonly string[] | null
}
export type LocalPreflightContext =
| {
wslDistro?: string | null
wslDefault?: boolean
runtimeContextKey?: string
projectRuntime?: ProjectExecutionRuntimeResolution
}
| undefined
const wslPreflightContextsByDistro = new Map<string, NonNullable<LocalPreflightContext>>()
const projectRuntimePreflightContextsByKey = new Map<string, NonNullable<LocalPreflightContext>>()
export function getWslDistroFromPath(path?: string | null): string | null {
return path ? (parseWslUncPath(path)?.distro ?? null) : null
}
function getWslPreflightContext(wslDistro: string): NonNullable<LocalPreflightContext> {
const cached = wslPreflightContextsByDistro.get(wslDistro)
if (cached) {
return cached
}
// Why: React/Zustand selectors must return a cached snapshot. A fresh object
// here triggers a useSyncExternalStore loop when Settings observes WSL repos.
const context = Object.freeze({ wslDistro })
wslPreflightContextsByDistro.set(wslDistro, context)
return context
}
function getProjectRuntimeContextObjectCacheKey(
resolution: ProjectExecutionRuntimeResolution
): string {
if (resolution.status === 'resolved') {
return `${resolution.runtime.cacheKey}:${resolution.runtime.reason}`
}
return `${resolution.repair.cacheKey}:${resolution.repair.source}`
}
function getProjectRuntimePreflightContext(
resolution: ProjectExecutionRuntimeResolution
): NonNullable<LocalPreflightContext> {
const cacheKey = getProjectRuntimeContextObjectCacheKey(resolution)
const cached = projectRuntimePreflightContextsByKey.get(cacheKey)
if (cached) {
return cached
}
const wslDistro =
resolution.status === 'resolved' && resolution.runtime.kind === 'wsl'
? resolution.runtime.distro
: undefined
// Why: selectors compare by reference; cache each resolved runtime context so
// adding projectRuntime does not reintroduce useSyncExternalStore churn.
const context = Object.freeze({
...(wslDistro ? { wslDistro } : {}),
projectRuntime: resolution
})
projectRuntimePreflightContextsByKey.set(cacheKey, context)
return context
}
export function getLocalProjectExecutionRuntimeContext(
state: LocalProjectRuntimeState,
worktreeId?: string | null,