fix(files): release expired mobile path inventories while idle (#24694)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-02 18:31:17 -07:00
committed by GitHub
co-authored by OrcaWin
parent 7c6a37eeba
commit 0b0152af5a
2 changed files with 222 additions and 5 deletions
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
import {
RuntimeMobileFilePathSearchCache,
type RuntimeMobileFilePathInventory
} from './runtime-mobile-file-path-search'
const TTL_MS = 30_000
async function collect(): Promise<void> {
if (typeof globalThis.gc !== 'function') {
throw new Error('Run with the repository Vitest --expose-gc config')
}
for (let round = 0; round < 3; round++) {
await new Promise<void>((resolve) => setImmediate(resolve))
globalThis.gc()
}
}
function inventory(path: string): RuntimeMobileFilePathInventory {
return { paths: [path], totalCount: 1, truncated: false }
}
async function rememberPaths(cache: RuntimeMobileFilePathSearchCache): Promise<WeakRef<string[]>> {
const paths = Array.from({ length: 20_000 }, (_, index) => `src/project/${index}/file.ts`)
await cache.get('ssh-host:workspace', async () => ({
paths,
totalCount: paths.length,
truncated: false
}))
return new WeakRef(paths)
}
describe('mobile file path inventory expiry', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.clearAllTimers()
vi.restoreAllMocks()
vi.useRealTimers()
})
it('releases the full idle inventory at its existing deadline', async () => {
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
const retained = await rememberPaths(cache)
vi.advanceTimersByTime(TTL_MS - 1)
await collect()
expect(retained.deref()).toBeDefined()
vi.advanceTimersByTime(1)
await collect()
expect(retained.deref() === undefined).toBe(true)
expect(vi.getTimerCount()).toBe(0)
await expect(cache.get('ssh-host:workspace', async () => inventory('new.ts'))).resolves.toEqual(
inventory('new.ts')
)
})
it('allows the cache owner and inventory to be collected before expiry', async () => {
async function releaseOwner() {
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
return { owner: new WeakRef(cache), paths: await rememberPaths(cache) }
}
const retired = await releaseOwner()
expect(vi.getTimerCount()).toBe(1)
await collect()
expect(retired.owner.deref() === undefined).toBe(true)
expect(retired.paths.deref() === undefined).toBe(true)
vi.advanceTimersByTime(TTL_MS)
expect(vi.getTimerCount()).toBe(0)
})
it('keeps cache hits through the original deadline without extending it', async () => {
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
const load = vi.fn(async () => inventory('file.ts'))
await cache.get('workspace', load)
vi.advanceTimersByTime(TTL_MS - 1)
await cache.get('workspace', load)
expect(load).toHaveBeenCalledOnce()
vi.advanceTimersByTime(1)
expect(vi.getTimerCount()).toBe(0)
await cache.get('workspace', load)
expect(load).toHaveBeenCalledTimes(2)
})
it('starts expiry when a slow SSH load settles and shares its pending request', async () => {
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
let resolveLoad: (value: RuntimeMobileFilePathInventory) => void = () => {}
const load = vi.fn(
() =>
new Promise<RuntimeMobileFilePathInventory>((resolve) => {
resolveLoad = resolve
})
)
const first = cache.get('ssh-host:workspace', load)
const second = cache.get('ssh-host:workspace', load)
vi.advanceTimersByTime(TTL_MS * 2)
expect(vi.getTimerCount()).toBe(0)
resolveLoad(inventory('file.ts'))
await Promise.all([first, second])
expect(load).toHaveBeenCalledOnce()
vi.advanceTimersByTime(TTL_MS - 1)
await cache.get('ssh-host:workspace', load)
expect(load).toHaveBeenCalledOnce()
vi.advanceTimersByTime(1)
expect(vi.getTimerCount()).toBe(0)
})
it('cancels evicted timers and ignores a retired callback after the same key reloads', async () => {
const timeout = vi.spyOn(globalThis, 'setTimeout')
const cache = new RuntimeMobileFilePathSearchCache(1, TTL_MS)
await cache.get('a', async () => inventory('old.ts'))
const retired = timeout.mock.calls[0]?.[0]
if (typeof retired !== 'function') {
throw new Error('Expected the original expiry callback')
}
vi.advanceTimersByTime(1000)
await cache.get('b', async () => inventory('other.ts'))
await cache.get('a', async () => inventory('replacement.ts'))
expect(vi.getTimerCount()).toBe(1)
const calls = timeout.mock.calls.length
retired()
expect(timeout).toHaveBeenCalledTimes(calls)
await expect(cache.get('a', async () => inventory('unexpected.ts'))).resolves.toMatchObject(
inventory('replacement.ts')
)
vi.advanceTimersByTime(TTL_MS)
expect(vi.getTimerCount()).toBe(0)
})
it('preserves caller-controlled explicit clocks without arming wall-clock timers', async () => {
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
const load = vi.fn(async () => inventory('file.ts'))
await cache.get('workspace', load, 0)
vi.advanceTimersByTime(TTL_MS * 2)
await cache.get('workspace', load, TTL_MS - 1)
expect(load).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
await cache.get('workspace', load, TTL_MS)
expect(load).toHaveBeenCalledTimes(2)
})
it('bounds delay after a backward wall-clock jump without evicting fresh paths', async () => {
const timeout = vi.spyOn(globalThis, 'setTimeout')
const cache = new RuntimeMobileFilePathSearchCache(8, TTL_MS)
const load = vi.fn(async () => inventory('file.ts'))
await cache.get('workspace', load)
vi.setSystemTime(-MAX_TIMER_DELAY_MS)
vi.advanceTimersByTime(TTL_MS)
expect(timeout.mock.calls.at(-1)?.[1]).toBe(MAX_TIMER_DELAY_MS)
await cache.get('workspace', load)
expect(load).toHaveBeenCalledOnce()
vi.advanceTimersByTime(MAX_TIMER_DELAY_MS)
expect(vi.getTimerCount()).toBe(0)
})
})
@@ -1,3 +1,5 @@
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
export type RuntimeMobileFilePathInventory = {
paths: string[]
totalCount: number
@@ -6,11 +8,15 @@ export type RuntimeMobileFilePathInventory = {
type CacheEntry = RuntimeMobileFilePathInventory & { expiresAt: number }
/** Lazy TTL/LRU cache for autocomplete inventories. It avoids launching rg for
/** TTL/LRU cache for autocomplete inventories. It avoids launching rg for
* every mobile keystroke while bounding retained worktrees and paths. */
export class RuntimeMobileFilePathSearchCache {
private readonly entries = new Map<string, CacheEntry>()
private readonly inFlight = new Map<string, Promise<RuntimeMobileFilePathInventory>>()
private readonly expirationTimers = new Map<
string,
{ token: symbol; timer: ReturnType<typeof setTimeout> }
>()
constructor(
private readonly maxEntries: number,
@@ -29,7 +35,7 @@ export class RuntimeMobileFilePathSearchCache {
this.entries.set(key, cached)
return cached
}
this.entries.delete(key)
this.removeEntry(key)
const pending = this.inFlight.get(key)
if (pending) {
return pending
@@ -38,13 +44,18 @@ export class RuntimeMobileFilePathSearchCache {
.then((loaded) => {
// Why: a slow SSH scan should receive a full TTL after it becomes usable,
// not arrive already expired because the clock started before its I/O.
this.entries.set(key, { ...loaded, expiresAt: (now ?? Date.now()) + this.ttlMs })
const expiresAt = (now ?? Date.now()) + this.ttlMs
this.entries.set(key, { ...loaded, expiresAt })
// Explicit per-call clocks stay caller-controlled; production uses the wall clock.
if (now === undefined) {
this.scheduleExpiry(key, expiresAt)
}
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value as string | undefined
const oldest = this.entries.keys().next().value
if (!oldest) {
break
}
this.entries.delete(oldest)
this.removeEntry(oldest)
}
return loaded
})
@@ -58,6 +69,53 @@ export class RuntimeMobileFilePathSearchCache {
this.inFlight.set(key, next)
return next
}
private removeEntry(key: string): void {
this.entries.delete(key)
const expiration = this.expirationTimers.get(key)
if (expiration) {
clearTimeout(expiration.timer)
this.expirationTimers.delete(key)
}
}
private scheduleExpiry(key: string, expiresAt: number): void {
const previous = this.expirationTimers.get(key)
if (previous) {
clearTimeout(previous.timer)
this.expirationTimers.delete(key)
}
if (!Number.isFinite(expiresAt)) {
return
}
const token = Symbol()
const timer = setTimeout(
RuntimeMobileFilePathSearchCache.expiryCallback(new WeakRef(this), key, token),
Math.min(MAX_TIMER_DELAY_MS, Math.max(0, expiresAt - Date.now()))
)
timer.unref()
this.expirationTimers.set(key, { token, timer })
}
private static expiryCallback(
owner: WeakRef<RuntimeMobileFilePathSearchCache>,
key: string,
token: symbol
): () => void {
return () => owner.deref()?.expireEntry(key, token)
}
private expireEntry(key: string, token: symbol): void {
if (this.expirationTimers.get(key)?.token !== token) {
return
}
const entry = this.entries.get(key)
if (!entry || Date.now() >= entry.expiresAt) {
this.removeEntry(key)
} else {
this.scheduleExpiry(key, entry.expiresAt)
}
}
}
/** Preserves composer ranking: full-path/basename prefixes first, then substring