Skip to content

Commit ef82a5e

Browse files
committed
fix(dlx): cap binary/package-json path caches and TTL negative hits
`binaryPathCache` (src/dlx/package.ts) and `packageJsonPathCache` (src/dlx/detect.ts) were plain `Map`s with no size limit — a long- running process (devserver, editor extension) that resolved many distinct paths accumulated entries forever. Adds a 200-entry LRU cap to both (Map insertion-order = recency, re-insert on hit). `packageJsonPathCache` also cached negative results (null) without any expiration. A directory that later gained a package.json (sibling `npm install`, `pnpm add`, workspace scaffolding) would permanently return undefined. Negative entries now expire after a 10s TTL and re-probe — still amortizing the walk in tight loops but not forever.
1 parent 555b6f4 commit ef82a5e

2 files changed

Lines changed: 62 additions & 13 deletions

File tree

src/dlx/detect.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,29 @@ let _path: typeof import('node:path') | undefined
2626
const NODE_JS_EXTENSIONS = new Set(['.js', '.mjs', '.cjs'] as const)
2727

2828
// Cache for package.json path lookups to avoid repeated directory traversal.
29-
const packageJsonPathCache = new Map<string, string | null>()
29+
// Bounded LRU so long-lived processes don't accumulate entries for every
30+
// distinct startDir they've ever seen. Negative entries (null) are
31+
// short-lived — we re-probe after TTL expiry so a dir that later gains
32+
// a package.json isn't permanently stuck at "none found".
33+
const PACKAGE_JSON_PATH_CACHE_MAX_SIZE = 200
34+
const PACKAGE_JSON_NEGATIVE_TTL_MS = 10_000
35+
type PackageJsonPathEntry = {
36+
path: string | null
37+
at: number
38+
}
39+
const packageJsonPathCache = new Map<string, PackageJsonPathEntry>()
40+
41+
function packageJsonPathCacheSet(key: string, value: string | null): void {
42+
if (packageJsonPathCache.has(key)) {
43+
packageJsonPathCache.delete(key)
44+
} else if (packageJsonPathCache.size >= PACKAGE_JSON_PATH_CACHE_MAX_SIZE) {
45+
const oldest = packageJsonPathCache.keys().next().value
46+
if (oldest !== undefined) {
47+
packageJsonPathCache.delete(oldest)
48+
}
49+
}
50+
packageJsonPathCache.set(key, { path: value, at: Date.now() })
51+
}
3052

3153
// Cache for parsed package.json content keyed by path + mtime so stale
3254
// content is not served if the file is modified or replaced.
@@ -62,15 +84,22 @@ function findPackageJson(filePath: string): string | undefined {
6284
// Check cache first.
6385
const cached = packageJsonPathCache.get(startDir)
6486
if (cached !== undefined) {
65-
// Validate cache - check if cached path still exists.
66-
if (cached === null) {
67-
return undefined
68-
}
69-
if (fs.existsSync(cached)) {
70-
return cached
87+
// Negative entries expire after a short TTL so a directory that later
88+
// gains a package.json (npm install in a sibling workspace, etc.) is
89+
// re-probed instead of permanently stuck on the cached "not found".
90+
if (cached.path === null) {
91+
if (Date.now() - cached.at < PACKAGE_JSON_NEGATIVE_TTL_MS) {
92+
return undefined
93+
}
94+
packageJsonPathCache.delete(startDir)
95+
} else if (fs.existsSync(cached.path)) {
96+
// Bump recency on hit.
97+
packageJsonPathCacheSet(startDir, cached.path)
98+
return cached.path
99+
} else {
100+
// Cached path no longer exists, remove stale entry.
101+
packageJsonPathCache.delete(startDir)
71102
}
72-
// Cached path no longer exists, remove stale entry.
73-
packageJsonPathCache.delete(startDir)
74103
}
75104

76105
let currentDir = startDir
@@ -80,15 +109,15 @@ function findPackageJson(filePath: string): string | undefined {
80109
const packageJsonPath = path.join(currentDir, 'package.json')
81110
if (fs.existsSync(packageJsonPath)) {
82111
// Cache the result for the starting directory.
83-
packageJsonPathCache.set(startDir, packageJsonPath)
112+
packageJsonPathCacheSet(startDir, packageJsonPath)
84113
return packageJsonPath
85114
}
86115

87116
currentDir = path.dirname(currentDir)
88117
}
89118

90119
// Cache the negative result.
91-
packageJsonPathCache.set(startDir, null)
120+
packageJsonPathCacheSet(startDir, null)
92121
return undefined
93122
}
94123

src/dlx/package.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,27 @@ const FIREWALL_BLOCK_SEVERITIES: ReadonlySet<string> = new Set([
6363
'high',
6464
])
6565

66-
// Cache for binary path resolution to avoid repeated extension checks on Windows.
66+
// Cache for binary path resolution to avoid repeated extension checks
67+
// on Windows. Bounded LRU: a long-running process that resolves many
68+
// distinct binary paths used to accumulate entries forever, and entries
69+
// for paths that have since been garbage-collected by `cleanDlxCache`
70+
// were never reclaimed. Map iteration order = insertion order; accessing
71+
// an entry re-inserts it to bump recency.
72+
const BINARY_PATH_CACHE_MAX_SIZE = 200
6773
const binaryPathCache = new Map<string, string>()
6874

75+
function binaryPathCacheSet(key: string, value: string): void {
76+
if (binaryPathCache.has(key)) {
77+
binaryPathCache.delete(key)
78+
} else if (binaryPathCache.size >= BINARY_PATH_CACHE_MAX_SIZE) {
79+
const oldest = binaryPathCache.keys().next().value
80+
if (oldest !== undefined) {
81+
binaryPathCache.delete(oldest)
82+
}
83+
}
84+
binaryPathCache.set(key, value)
85+
}
86+
6987
interface FirewallAlert {
7088
severity?: string
7189
type?: string
@@ -887,6 +905,8 @@ export function resolveBinaryPath(basePath: string): string {
887905
const cached = binaryPathCache.get(basePath)
888906
if (cached) {
889907
if (fs.existsSync(cached)) {
908+
// Bump recency on hit.
909+
binaryPathCacheSet(basePath, cached)
890910
return cached
891911
}
892912
// Cached path no longer exists, remove stale entry.
@@ -901,7 +921,7 @@ export function resolveBinaryPath(basePath: string): string {
901921
const testPath = basePath + ext
902922
if (fs.existsSync(testPath)) {
903923
// Cache the result.
904-
binaryPathCache.set(basePath, testPath)
924+
binaryPathCacheSet(basePath, testPath)
905925
return testPath
906926
}
907927
}

0 commit comments

Comments
 (0)