mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user