Expanding a project with more worktrees and sessions than MAX_DIR_STORES put the sidebar into an endless request loop (#1472). Every sidebar row calls ensureChild during render, but the pin that protects the directory is only taken in an effect after commit. ensureChild marked the directory and ran eviction synchronously, so directories that were actively rendering looked unpinned and were disposed. The next render recreated them with a loading status, which issued another bootstrap request, and the cycle repeated for as long as the project stayed expanded. Raising the limit only moves the cliff, so the limit is now a soft target instead: a directory touched within a grace window is never an overflow victim. A burst of live directories overflows the cache briefly rather than thrashing, while idle-time eviction still bounds it. Eviction is also coalesced into one deferred pass per tick, so a render that mounts many rows no longer sorts and scans every directory once per row, and a whole commit's pin effects settle before anything is considered for disposal. Releasing the final consumer stays synchronous, since that is an explicit lifecycle edge. The idle profiler gains --expand-projects to reach this state. Not yet verified end to end: reproducing the loop needs many worktrees under one project, which this development environment does not have.
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
|
|
import { pickDirectoriesToEvict } from "./eviction"
|
|
import { DIR_IDLE_TTL_MS, EVICTION_GRACE_MS, MAX_DIR_STORES } from "./types"
|
|
|
|
/**
|
|
* Regression coverage for the sidebar cache-thrash loop (issue #1472).
|
|
*
|
|
* Expanding a project with many worktrees mounts a sidebar row per directory.
|
|
* Each row calls `ensureChild` during render, while the pin that protects it is
|
|
* only taken in an effect after commit. With more live directories than the
|
|
* store limit, overflow eviction therefore disposed directories that were
|
|
* actively being rendered; the next render recreated them with a `loading`
|
|
* status, which issued another bootstrap request, and the cycle repeated
|
|
* indefinitely.
|
|
*
|
|
* The fix treats the limit as a soft target: a directory touched within the
|
|
* grace window is never an overflow victim, so a burst of live directories
|
|
* overflows the cache briefly instead of thrashing. Idle directories stay
|
|
* evictable, which is what keeps the cache bounded.
|
|
*/
|
|
|
|
const buildState = (directories: string[], lastAccessAt: number) =>
|
|
new Map(directories.map((directory) => [directory, { lastAccessAt }]))
|
|
|
|
const directories = (count: number, prefix = "/repo/worktree-") =>
|
|
Array.from({ length: count }, (_, index) => `${prefix}${index}`)
|
|
|
|
describe("directory eviction under sidebar expansion", () => {
|
|
test("does not evict directories that are being accessed right now", () => {
|
|
const now = 1_000_000
|
|
const stores = directories(MAX_DIR_STORES + 25)
|
|
|
|
const evicted = pickDirectoriesToEvict({
|
|
stores,
|
|
state: buildState(stores, now),
|
|
pins: new Set(),
|
|
max: MAX_DIR_STORES,
|
|
ttl: DIR_IDLE_TTL_MS,
|
|
graceMs: EVICTION_GRACE_MS,
|
|
now,
|
|
})
|
|
|
|
expect(evicted).toEqual([])
|
|
})
|
|
|
|
test("still evicts overflow once directories fall outside the grace window", () => {
|
|
const now = 1_000_000
|
|
const live = directories(MAX_DIR_STORES, "/repo/live-")
|
|
const stale = directories(5, "/repo/stale-")
|
|
const state = new Map([
|
|
...buildState(live, now),
|
|
...buildState(stale, now - EVICTION_GRACE_MS - 1),
|
|
])
|
|
|
|
const evicted = pickDirectoriesToEvict({
|
|
stores: [...live, ...stale],
|
|
state,
|
|
pins: new Set(),
|
|
max: MAX_DIR_STORES,
|
|
ttl: DIR_IDLE_TTL_MS,
|
|
graceMs: EVICTION_GRACE_MS,
|
|
now,
|
|
})
|
|
|
|
expect([...evicted].sort()).toEqual([...stale].sort())
|
|
})
|
|
|
|
test("still evicts directories idle past the TTL even inside the limit", () => {
|
|
const now = 1_000_000
|
|
const active = directories(3, "/repo/active-")
|
|
const abandoned = directories(2, "/repo/abandoned-")
|
|
const state = new Map([
|
|
...buildState(active, now),
|
|
...buildState(abandoned, now - DIR_IDLE_TTL_MS - 1),
|
|
])
|
|
|
|
const evicted = pickDirectoriesToEvict({
|
|
stores: [...active, ...abandoned],
|
|
state,
|
|
pins: new Set(),
|
|
max: MAX_DIR_STORES,
|
|
ttl: DIR_IDLE_TTL_MS,
|
|
graceMs: EVICTION_GRACE_MS,
|
|
now,
|
|
})
|
|
|
|
expect([...evicted].sort()).toEqual([...abandoned].sort())
|
|
})
|
|
|
|
test("never evicts pinned or blocked directories regardless of overflow", () => {
|
|
const now = 1_000_000
|
|
const stores = directories(MAX_DIR_STORES + 10)
|
|
const stale = now - EVICTION_GRACE_MS - 1
|
|
|
|
const evicted = pickDirectoriesToEvict({
|
|
stores,
|
|
state: buildState(stores, stale),
|
|
pins: new Set([stores[0]]),
|
|
max: MAX_DIR_STORES,
|
|
ttl: DIR_IDLE_TTL_MS,
|
|
graceMs: EVICTION_GRACE_MS,
|
|
now,
|
|
hasPendingBlockingRequests: (directory) => directory === stores[1],
|
|
})
|
|
|
|
expect(evicted).not.toContain(stores[0])
|
|
expect(evicted).not.toContain(stores[1])
|
|
})
|
|
})
|