| author | |
| committer | |
| log | 2743e45dbed2895e47aed18bbabb36e8dae54247 |
| tree | e6637b6e825a2aa48fecf040eec5e4681fcb9b8f |
| parent | 1b409c39468298a2a0c01d988fc2d90340877f13 |
- Reuse in-flight workspace index builds per cwd and cap `readdir` scan concurrency at 32
- Add server tests for build deduplication and directory read concurrency limits
- Track composer text in a ref so prompt replacements use current text after send/reset3 files changed, 122 insertions(+), 21 deletions(-)
apps/server/src/workspaceEntries.test.ts+59-1| ... | ... | @@ -1,9 +1,10 @@ |
| 1 | 1 | import fs from "node:fs"; |
| 2 | import fsPromises from "node:fs/promises"; | |
| 2 | 3 | import os from "node:os"; |
| 3 | 4 | import path from "node:path"; |
| 4 | 5 | import { spawnSync } from "node:child_process"; |
| 5 | 6 | |
| 6 | import { afterEach, assert, describe, it } from "vitest"; | |
| 7 | import { afterEach, assert, describe, it, vi } from "vitest"; | |
| 7 | 8 | |
| 8 | 9 | import { searchWorkspaceEntries } from "./workspaceEntries"; |
| 9 | 10 | |
| ... | ... | @@ -30,6 +31,7 @@ function runGit(cwd: string, args: string[]): void { |
| 30 | 31 | |
| 31 | 32 | describe("searchWorkspaceEntries", () => { |
| 32 | 33 | afterEach(() => { |
| 34 | vi.restoreAllMocks(); | |
| 33 | 35 | for (const dir of tempDirs.splice(0, tempDirs.length)) { |
| 34 | 36 | fs.rmSync(dir, { recursive: true, force: true }); |
| 35 | 37 | } |
| ... | ... | @@ -115,4 +117,60 @@ describe("searchWorkspaceEntries", () => { |
| 115 | 117 | assert.include(paths, "src/keep.ts"); |
| 116 | 118 | assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); |
| 117 | 119 | }); |
| 120 | ||
| 121 | it("deduplicates concurrent index builds for the same cwd", async () => { | |
| 122 | const cwd = makeTempDir("t3code-workspace-concurrent-build-"); | |
| 123 | writeFile(cwd, "src/components/Composer.tsx"); | |
| 124 | ||
| 125 | let rootReadCount = 0; | |
| 126 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 127 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 128 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 129 | if (args[0] === cwd) { | |
| 130 | rootReadCount += 1; | |
| 131 | await new Promise((resolve) => setTimeout(resolve, 20)); | |
| 132 | } | |
| 133 | return originalReaddir(...args); | |
| 134 | }) as typeof fsPromises.readdir, | |
| 135 | ); | |
| 136 | ||
| 137 | await Promise.all([ | |
| 138 | searchWorkspaceEntries({ cwd, query: "", limit: 100 }), | |
| 139 | searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), | |
| 140 | searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), | |
| 141 | ]); | |
| 142 | ||
| 143 | assert.equal(rootReadCount, 1); | |
| 144 | }); | |
| 145 | ||
| 146 | it("limits concurrent directory reads while walking the filesystem", async () => { | |
| 147 | const cwd = makeTempDir("t3code-workspace-read-concurrency-"); | |
| 148 | for (let index = 0; index < 80; index += 1) { | |
| 149 | writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"); | |
| 150 | } | |
| 151 | ||
| 152 | let activeReads = 0; | |
| 153 | let peakReads = 0; | |
| 154 | const originalReaddir = fsPromises.readdir.bind(fsPromises); | |
| 155 | vi.spyOn(fsPromises, "readdir").mockImplementation( | |
| 156 | (async (...args: Parameters<typeof fsPromises.readdir>) => { | |
| 157 | const target = args[0]; | |
| 158 | if (typeof target === "string" && target.startsWith(cwd)) { | |
| 159 | activeReads += 1; | |
| 160 | peakReads = Math.max(peakReads, activeReads); | |
| 161 | await new Promise((resolve) => setTimeout(resolve, 4)); | |
| 162 | try { | |
| 163 | return await originalReaddir(...args); | |
| 164 | } finally { | |
| 165 | activeReads -= 1; | |
| 166 | } | |
| 167 | } | |
| 168 | return originalReaddir(...args); | |
| 169 | }) as typeof fsPromises.readdir, | |
| 170 | ); | |
| 171 | ||
| 172 | await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); | |
| 173 | ||
| 174 | assert.isAtMost(peakReads, 32); | |
| 175 | }); | |
| 118 | 176 | }); |
apps/server/src/workspaceEntries.ts+51-10| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | const WORKSPACE_CACHE_TTL_MS = 15_000; |
| 14 | 14 | const WORKSPACE_CACHE_MAX_KEYS = 4; |
| 15 | 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 16 | const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; | |
| 16 | 17 | const IGNORED_DIRECTORY_NAMES = new Set([ |
| 17 | 18 | ".git", |
| 18 | 19 | ".convex", |
| ... | ... | @@ -32,6 +33,7 @@ interface WorkspaceIndex { |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | const workspaceIndexCache = new Map<string, WorkspaceIndex>(); |
| 36 | const inFlightWorkspaceIndexBuilds = new Map<string, Promise<WorkspaceIndex>>(); | |
| 35 | 37 | |
| 36 | 38 | function toPosixPath(input: string): string { |
| 37 | 39 | return input.split(path.sep).join("/"); |
| ... | ... | @@ -101,6 +103,31 @@ function directoryAncestorsOf(relativePath: string): string[] { |
| 101 | 103 | return directories; |
| 102 | 104 | } |
| 103 | 105 | |
| 106 | async function mapWithConcurrency<TInput, TOutput>( | |
| 107 | items: readonly TInput[], | |
| 108 | concurrency: number, | |
| 109 | mapper: (item: TInput, index: number) => Promise<TOutput>, | |
| 110 | ): Promise<TOutput[]> { | |
| 111 | if (items.length === 0) { | |
| 112 | return []; | |
| 113 | } | |
| 114 | ||
| 115 | const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); | |
| 116 | const results = Array.from({ length: items.length }) as TOutput[]; | |
| 117 | let nextIndex = 0; | |
| 118 | ||
| 119 | const workers = Array.from({ length: boundedConcurrency }, async () => { | |
| 120 | while (nextIndex < items.length) { | |
| 121 | const currentIndex = nextIndex; | |
| 122 | nextIndex += 1; | |
| 123 | results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | await Promise.all(workers); | |
| 128 | return results; | |
| 129 | } | |
| 130 | ||
| 104 | 131 | async function isInsideGitWorkTree(cwd: string): Promise<boolean> { |
| 105 | 132 | const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { |
| 106 | 133 | cwd, |
| ... | ... | @@ -220,8 +247,10 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 220 | 247 | while (pendingDirectories.length > 0 && !truncated) { |
| 221 | 248 | const currentDirectories = pendingDirectories; |
| 222 | 249 | pendingDirectories = []; |
| 223 | const directoryEntries = await Promise.all( | |
| 224 | currentDirectories.map(async (relativeDir) => { | |
| 250 | const directoryEntries = await mapWithConcurrency( | |
| 251 | currentDirectories, | |
| 252 | WORKSPACE_SCAN_READDIR_CONCURRENCY, | |
| 253 | async (relativeDir) => { | |
| 225 | 254 | const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; |
| 226 | 255 | try { |
| 227 | 256 | const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); |
| ... | ... | @@ -235,7 +264,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 235 | 264 | } |
| 236 | 265 | return { relativeDir, dirents: null }; |
| 237 | 266 | } |
| 238 | }), | |
| 267 | }, | |
| 239 | 268 | ); |
| 240 | 269 | |
| 241 | 270 | const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { |
| ... | ... | @@ -315,14 +344,26 @@ async function getWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { |
| 315 | 344 | return cached; |
| 316 | 345 | } |
| 317 | 346 | |
| 318 | const next = await buildWorkspaceIndex(cwd); | |
| 319 | workspaceIndexCache.set(cwd, next); | |
| 320 | while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { | |
| 321 | const oldestKey = workspaceIndexCache.keys().next().value; | |
| 322 | if (!oldestKey) break; | |
| 323 | workspaceIndexCache.delete(oldestKey); | |
| 347 | const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); | |
| 348 | if (inFlight) { | |
| 349 | return inFlight; | |
| 324 | 350 | } |
| 325 | return next; | |
| 351 | ||
| 352 | const nextPromise = buildWorkspaceIndex(cwd) | |
| 353 | .then((next) => { | |
| 354 | workspaceIndexCache.set(cwd, next); | |
| 355 | while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { | |
| 356 | const oldestKey = workspaceIndexCache.keys().next().value; | |
| 357 | if (!oldestKey) break; | |
| 358 | workspaceIndexCache.delete(oldestKey); | |
| 359 | } | |
| 360 | return next; | |
| 361 | }) | |
| 362 | .finally(() => { | |
| 363 | inFlightWorkspaceIndexBuilds.delete(cwd); | |
| 364 | }); | |
| 365 | inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); | |
| 366 | return nextPromise; | |
| 326 | 367 | } |
| 327 | 368 | |
| 328 | 369 | export async function searchWorkspaceEntries( |
apps/web/src/components/ChatView.tsx+12-10| ... | ... | @@ -329,6 +329,7 @@ export default function ChatView() { |
| 329 | 329 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 330 | 330 | ); |
| 331 | 331 | const [prompt, setPrompt] = useState(""); |
| 332 | const promptRef = useRef(prompt); | |
| 332 | 333 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 333 | 334 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 334 | 335 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -1126,6 +1127,7 @@ export default function ChatView() { |
| 1126 | 1127 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 1127 | 1128 | }); |
| 1128 | 1129 | const previousMessages = activeThread.messages; |
| 1130 | promptRef.current = ""; | |
| 1129 | 1131 | setPrompt(""); |
| 1130 | 1132 | setComposerImages([]); |
| 1131 | 1133 | setComposerCursor(0); |
| ... | ... | @@ -1238,16 +1240,15 @@ export default function ChatView() { |
| 1238 | 1240 | |
| 1239 | 1241 | const applyPromptReplacement = useCallback( |
| 1240 | 1242 | (rangeStart: number, rangeEnd: number, replacement: string) => { |
| 1241 | setPrompt((existing) => { | |
| 1242 | const next = replaceTextRange(existing, rangeStart, rangeEnd, replacement); | |
| 1243 | window.requestAnimationFrame(() => { | |
| 1244 | const textarea = textareaRef.current; | |
| 1245 | if (!textarea) return; | |
| 1246 | textarea.focus(); | |
| 1247 | textarea.setSelectionRange(next.cursor, next.cursor); | |
| 1248 | setComposerCursor(next.cursor); | |
| 1249 | }); | |
| 1250 | return next.text; | |
| 1243 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); | |
| 1244 | promptRef.current = next.text; | |
| 1245 | setPrompt(next.text); | |
| 1246 | window.requestAnimationFrame(() => { | |
| 1247 | const textarea = textareaRef.current; | |
| 1248 | if (!textarea) return; | |
| 1249 | textarea.focus(); | |
| 1250 | textarea.setSelectionRange(next.cursor, next.cursor); | |
| 1251 | setComposerCursor(next.cursor); | |
| 1251 | 1252 | }); |
| 1252 | 1253 | }, |
| 1253 | 1254 | [], |
| ... | ... | @@ -1284,6 +1285,7 @@ export default function ChatView() { |
| 1284 | 1285 | workspaceEntriesQuery.isFetching); |
| 1285 | 1286 | |
| 1286 | 1287 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1288 | promptRef.current = nextPrompt; | |
| 1287 | 1289 | setPrompt(nextPrompt); |
| 1288 | 1290 | setComposerCursor(nextCursor); |
| 1289 | 1291 | }, []); |