From d386fa8262c4e3220c56ea32e9243308a1731ce4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:57:15 -0700 Subject: [PATCH] fix(ai-vault): bound raced Cursor sidecar reads --- .../session-scanner-cursor-local-pipeline.ts | 44 ++++++++++---- ...sion-scanner-cursor-storage-budget.test.ts | 40 +++++++++++++ src/relay/cursor-sidecar-scan.test.ts | 60 ++++++++++++++++++- src/relay/cursor-sidecar-scan.ts | 9 +++ 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts index 05ff26c7240..3dfa0bae08a 100644 --- a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts +++ b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts @@ -103,7 +103,7 @@ type VerifiedReadTracker = { budgetsByStorageKey: Map } -type VerifiedReadBudget = { returnedBytes: number; gate: Promise } +type VerifiedReadBudget = { chargedBytes: number; gate: Promise } function createVerifiedReadTracker( discoveries: readonly SessionFileDiscovery[] | undefined @@ -154,7 +154,7 @@ function verifiedReadBudget(tracker: VerifiedReadTracker, storageKey: string): V if (existing) { return existing } - const created = { returnedBytes: 0, gate: Promise.resolve() } + const created = { chargedBytes: 0, gate: Promise.resolve() } tracker.budgetsByStorageKey.set(storageKey, created) return created } @@ -243,26 +243,40 @@ async function parseSidecarWithAggregateCap( // Gate covers reserve → verified read → commit of actual returned bytes. return withVerifiedReadGate(budget, async () => { throwIfAiVaultScanCancelled(args.signal) - if (budget.returnedBytes >= CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { + if (budget.chargedBytes >= CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { if (storage) { storage.truncated.sidecarBytes = true } return null } - if (budget.returnedBytes + estimatedBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { + if (budget.chargedBytes + estimatedBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { if (storage) { storage.truncated.sidecarBytes = true } return null } - const result = await parseCursorSidecarFileCached({ - file: candidate.file, - platform: args.platform, - targetPlatform: candidate.cursorTargetPlatform, - executionHostId: args.executionHostId, - expectedRootRealPath: candidate.cursorExpectedRootRealPath - }) + let result + try { + result = await parseCursorSidecarFileCached({ + file: candidate.file, + platform: args.platform, + targetPlatform: candidate.cursorTargetPlatform, + executionHostId: args.executionHostId, + expectedRootRealPath: candidate.cursorExpectedRootRealPath + }) + } catch (error) { + if (storage) { + storage.counters.boundedReads += 1 + } + if (isVerifiedReadTooLargeError(error)) { + budget.chargedBytes = CURSOR_REMOTE_MAX_AGGREGATE_BYTES + if (storage) { + storage.truncated.sidecarBytes = true + } + } + throw error + } if (!result.cacheHit) { const readBytes = result.returnedBytes ?? 0 @@ -271,8 +285,8 @@ async function parseSidecarWithAggregateCap( storage.counters.boundedReads += 1 storage.counters.returnedBytes += readBytes } - budget.returnedBytes += readBytes - if (budget.returnedBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { + budget.chargedBytes += readBytes + if (budget.chargedBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { if (storage) { storage.truncated.sidecarBytes = true } @@ -296,3 +310,7 @@ async function parseSidecarWithAggregateCap( : null }) } + +function isVerifiedReadTooLargeError(error: unknown): boolean { + return error instanceof Error && error.message === 'file_too_large' +} diff --git a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts index 36556fd5b51..446a4b16780 100644 --- a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts @@ -3,6 +3,7 @@ import { lstat, mkdir, mkdtemp, realpath, rm, utimes, writeFile } from 'node:fs/ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { CURSOR_REMOTE_MAX_AGGREGATE_BYTES, CURSOR_SIDECAR_MAX_BYTES @@ -115,6 +116,45 @@ describe('Cursor verified-read budgets by storage context', () => { } }, 30_000) + it('stops after a raced sidecar grows past the verified-read limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-storage-oversized-race-')) + roots.push(root) + const chatsRoot = join(root, '.cursor', 'chats') + const files = await Promise.all([ + addSession(chatsRoot, 'race-first', sidecarPayload(1_000), 2_000), + addSession(chatsRoot, 'race-second', sidecarPayload(1_000), 1_000) + ]) + await Promise.all( + files.map((file) => writeFile(file.path, sidecarPayload(CURSOR_SIDECAR_MAX_BYTES + 1))) + ) + const discovery = sidecarDiscovery('native', chatsRoot, await realpath(chatsRoot), files) + const span = startSpan('cursor-storage-oversized-race-test') + const issues: AiVaultScanIssue[] = [] + + try { + const result = await processLocalCursorCandidates({ + candidates: discoveryCandidates(discovery), + discoveries: [discovery], + executionHostId: 'local', + issues, + limit: 20, + parseStats: createSessionParseStats(), + platform: 'linux', + scopeLimit: 20, + span + }) + + expect(result.sessions).toEqual([]) + expect(discovery.cursorDiscoveryCounters?.boundedReads).toBe(1) + expect(discovery.cursorDiscoveryCounters?.returnedBytes).toBe(0) + expect(discovery.cursorDiscoveryTruncated?.sidecarBytes).toBe(true) + expect(issues).toHaveLength(1) + expect(issues[0]?.message).toContain('file_too_large') + } finally { + span.end() + } + }) + it('does not let native reads consume the WSL ingress budget', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-cursor-storage-budget-')) roots.push(root) diff --git a/src/relay/cursor-sidecar-scan.test.ts b/src/relay/cursor-sidecar-scan.test.ts index 41ef2ec1d05..acaccbc1d0e 100644 --- a/src/relay/cursor-sidecar-scan.test.ts +++ b/src/relay/cursor-sidecar-scan.test.ts @@ -1,13 +1,18 @@ +import { writeFileSync } from 'node:fs' import { chmod, mkdtemp, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it } from 'vitest' import { scanCursorSidecars } from './cursor-sidecar-scan' -import { defaultCursorSidecarScanRequest } from '../shared/cursor-sidecar-scan' +import { + CURSOR_SIDECAR_MAX_BYTES, + defaultCursorSidecarScanRequest +} from '../shared/cursor-sidecar-scan' import { cursorBucketForCwd } from '../main/ai-vault/session-scanner-cursor-paths' const roots: string[] = [] const context = { clientId: 1, isStale: () => false } +const CHECKS_BEFORE_FIRST_VERIFIED_READ = 8 async function createRoot(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-cursor-scan-')) @@ -287,6 +292,59 @@ describe('scanCursorSidecars', () => { ).rejects.toThrow('cursor_sidecar_scan_cancelled') }) + it('rejects when cancellation lands during the final verified sidecar read', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + await addSession(chatsRoot, 'cccccccccccccccccccccccccccccccc', 'alive') + let checks = 0 + + await expect( + scanCursorSidecars(defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), { + clientId: 1, + isStale: () => { + checks += 1 + return checks > CHECKS_BEFORE_FIRST_VERIFIED_READ + } + }) + ).rejects.toThrow('cursor_sidecar_scan_cancelled') + }) + + it('stops after a raced sidecar grows past the verified-read limit', async () => { + const root = await createRoot() + const chatsRoot = join(root, 'chats') + const bucket = 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd' + await Promise.all([ + addSession(chatsRoot, bucket, 'first'), + addSession(chatsRoot, bucket, 'second') + ]) + let checks = 0 + + const result = await scanCursorSidecars( + defaultCursorSidecarScanRequest(chatsRoot, [], process.platform), + { + clientId: 1, + isStale: () => { + checks += 1 + if (checks === CHECKS_BEFORE_FIRST_VERIFIED_READ) { + for (const sessionId of ['first', 'second']) { + writeFileSync( + join(chatsRoot, bucket, sessionId, 'meta.json'), + 'x'.repeat(CURSOR_SIDECAR_MAX_BYTES + 1) + ) + } + } + return false + } + } + ) + + expect(result.sidecars).toEqual([]) + expect(result.counters.boundedReads).toBe(1) + expect(result.counters.returnedBytes).toBe(0) + expect(result.truncated.sidecarBytes).toBe(true) + expect(result.issues).toContainEqual(expect.objectContaining({ message: 'file_too_large' })) + }) + it('retains the newer equal-size session under a tight aggregate byte cap', async () => { const root = await createRoot() const chatsRoot = join(root, 'chats') diff --git a/src/relay/cursor-sidecar-scan.ts b/src/relay/cursor-sidecar-scan.ts index 54634c378c8..b4de91a0dec 100644 --- a/src/relay/cursor-sidecar-scan.ts +++ b/src/relay/cursor-sidecar-scan.ts @@ -80,6 +80,7 @@ async function readCandidates( expectedRootRealPath: rootRealPath, maxBytes: caps.sidecarBytes }) + throwIfCancelled(context) const bytes = Buffer.byteLength(content, 'utf8') if (response.counters.returnedBytes + bytes > caps.aggregateBytes) { response.truncated.sidecarBytes = true @@ -107,6 +108,10 @@ async function readCandidates( if (!isMissing(error)) { addIssue(response, candidate.metaPath, error) } + if (isVerifiedReadTooLargeError(error)) { + response.truncated.sidecarBytes = true + break + } } } } @@ -166,6 +171,10 @@ function isMissing(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR' } +function isVerifiedReadTooLargeError(error: unknown): boolean { + return error instanceof Error && error.message === 'file_too_large' +} + function cancellationFromContext(context: RequestContext): CursorSidecarScanCancellation { return { throwIfCancelled: () => {