From 0b0152af5aba8feb0d309a71f73dbf656688b198 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 18:31:17 -0700 Subject: [PATCH] fix(files): release expired mobile path inventories while idle (#24694) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...ime-mobile-file-path-search-expiry.test.ts | 159 ++++++++++++++++++ .../runtime-mobile-file-path-search.ts | 68 +++++++- 2 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts diff --git a/src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts b/src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts new file mode 100644 index 00000000000..2c6775089d9 --- /dev/null +++ b/src/main/runtime/runtime-mobile-file-path-search-expiry.test.ts @@ -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 { + 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((resolve) => setImmediate(resolve)) + globalThis.gc() + } +} + +function inventory(path: string): RuntimeMobileFilePathInventory { + return { paths: [path], totalCount: 1, truncated: false } +} + +async function rememberPaths(cache: RuntimeMobileFilePathSearchCache): Promise> { + 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((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) + }) +}) diff --git a/src/main/runtime/runtime-mobile-file-path-search.ts b/src/main/runtime/runtime-mobile-file-path-search.ts index 1455028c798..5955ad5ec39 100644 --- a/src/main/runtime/runtime-mobile-file-path-search.ts +++ b/src/main/runtime/runtime-mobile-file-path-search.ts @@ -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() private readonly inFlight = new Map>() + private readonly expirationTimers = new Map< + string, + { token: symbol; timer: ReturnType } + >() 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, + 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