mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix(ai-vault): bound raced Cursor sidecar reads
This commit is contained in:
@@ -103,7 +103,7 @@ type VerifiedReadTracker = {
|
||||
budgetsByStorageKey: Map<string, VerifiedReadBudget>
|
||||
}
|
||||
|
||||
type VerifiedReadBudget = { returnedBytes: number; gate: Promise<void> }
|
||||
type VerifiedReadBudget = { chargedBytes: number; gate: Promise<void> }
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string> {
|
||||
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')
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
Reference in New Issue
Block a user