From f20393dbb74be34b54388af363759b5ac18b9ed7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 28 May 2026 16:38:50 +0800 Subject: [PATCH] Bound workspace git service caches to fix daemon memory leak (#1200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven Map-based caches in WorkspaceGitServiceImpl had no eviction — the 15s TTL only marked entries stale before refetching in place. Long- lived daemons accumulated entries for every ephemeral worktree cwd that ever ran, with checkoutDiffCache holding multi-MB highlighted diffs. Switch to LRUCache. checkoutDiffCache caps at 64 (heavy values); the other six aux caches cap at 256. Stale-in-place TTL semantics are unchanged. --- package-lock.json | 130 +++++++++--------- packages/server/package.json | 1 + .../src/server/workspace-git-service.test.ts | 26 ++++ .../src/server/workspace-git-service.ts | 37 ++--- 4 files changed, 115 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 805f83e18..e23b3cf97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10297,12 +10297,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -10311,12 +10311,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -10325,12 +10325,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -10339,12 +10339,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -10353,12 +10353,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -10367,12 +10367,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -10381,12 +10381,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -10395,12 +10395,12 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -10409,12 +10409,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -10423,12 +10423,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -10437,12 +10437,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -10451,12 +10451,12 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -10465,12 +10465,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -10479,12 +10479,12 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -10493,12 +10493,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -10507,12 +10507,12 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -10521,12 +10521,12 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -10535,12 +10535,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -10549,12 +10549,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -10563,12 +10563,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -10577,12 +10577,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -10591,12 +10591,12 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -10605,12 +10605,12 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -10619,12 +10619,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -10633,12 +10633,12 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -12497,7 +12497,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -12507,7 +12506,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -16595,7 +16594,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -18216,6 +18214,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -22542,7 +22541,7 @@ "version": "4.13.6", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -22815,7 +22814,7 @@ "version": "16.8.1", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" @@ -31112,7 +31111,6 @@ "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -31785,7 +31783,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -31947,7 +31945,6 @@ "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -34422,7 +34419,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -35249,7 +35246,6 @@ "version": "7.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.27.0", @@ -35344,7 +35340,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -36261,7 +36256,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -37359,7 +37353,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.1.82", + "version": "0.1.83", "dependencies": { "zod": "^3.23.8" }, @@ -37617,6 +37611,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "lru-cache": "^11.5.1", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", @@ -38065,6 +38060,15 @@ "node": ">=18" } }, + "packages/server/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "packages/server/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 02bcdc80a..9abcef56f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -78,6 +78,7 @@ "dotenv": "^17.2.3", "express": "^4.18.2", "fast-deep-equal": "^3.1.3", + "lru-cache": "^11.5.1", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.11", "onnxruntime-node": "^1.23.0", diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index fde84414f..f99a39e2b 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -897,4 +897,30 @@ describe("WorkspaceGitServiceImpl", () => { workspaceSubscription.unsubscribe(); service.dispose(); }); + + test("checkoutDiffCache evicts least-recently-used entries past its size cap", async () => { + vi.useRealTimers(); + const getCheckoutDiff = vi.fn(async (cwd: string) => ({ + diff: `diff for ${cwd}`, + })); + const service = createService({ + getCheckoutDiff: getCheckoutDiff as unknown as ReturnType, + }); + + const CACHE_MAX = 64; + const OVERFLOW = 5; + + for (let i = 0; i < CACHE_MAX + OVERFLOW; i++) { + await service.getCheckoutDiff(`/tmp/repo-${i}`, { mode: "uncommitted" }); + } + expect(getCheckoutDiff).toHaveBeenCalledTimes(CACHE_MAX + OVERFLOW); + + await service.getCheckoutDiff(`/tmp/repo-${CACHE_MAX - 1}`, { mode: "uncommitted" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(CACHE_MAX + OVERFLOW); + + await service.getCheckoutDiff("/tmp/repo-0", { mode: "uncommitted" }); + expect(getCheckoutDiff).toHaveBeenCalledTimes(CACHE_MAX + OVERFLOW + 1); + + service.dispose(); + }); }); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 3fea970e1..1d7b5e374 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,6 +1,7 @@ import { watch, type FSWatcher } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { LRUCache } from "lru-cache"; import pLimit from "p-limit"; import type pino from "pino"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; @@ -47,6 +48,10 @@ const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000; const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; +// Heavy values (multi-MB highlighted diffs); cap aggressively. Ephemeral worktree cwds would otherwise pile up forever. +const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64; +// Small values (booleans, short strings, small arrays); generous cap. +const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; const WORKSPACE_GIT_FACTS_REUSE_TTL_MS = 1_000; const LINUX_WATCH_MAX_DIRS = 5_000; const LINUX_WATCH_REFRESH_COOLDOWN_MS = 2_000; @@ -349,34 +354,34 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly workingTreeWatchTargets = new Map(); private readonly workingTreeWatchSetups = new Map>(); private readonly linuxIgnoredDirsCache = new Map; ts: number }>(); - private readonly branchValidationCache = new Map< + private readonly branchValidationCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly localBranchCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly localBranchCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly branchSuggestionsCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly branchSuggestionsCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly stashListCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly stashListCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly worktreeListCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly worktreeListCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly defaultBranchCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly defaultBranchCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); - private readonly checkoutDiffCache = new Map< + >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); + private readonly checkoutDiffCache = new LRUCache< string, WorkspaceGitAuxiliaryReadCacheEntry - >(); + >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; @@ -697,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private readAuxiliaryCache( - cache: Map>, + cache: LRUCache>, key: string, options: WorkspaceGitReadOptions | undefined, load: () => Promise, @@ -739,7 +744,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private ensureAuxiliaryCacheEntry( - cache: Map>, + cache: LRUCache>, key: string, ): WorkspaceGitAuxiliaryReadCacheEntry { const existing = cache.get(key);