fix(lsp): throttle diagnostic storms (#1698)

* fix(lsp): throttle diagnostic storms

* fix(lsp): account for capped storm diagnostics
This commit is contained in:
Bogdan
2026-06-18 09:03:43 +08:00
committed by GitHub
parent 916f2477f3
commit 8cd463383d
4 changed files with 791 additions and 130 deletions
@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import type { Diagnostic, DiagnosticFile } from '../diagnosticTracking.js'
const debugMessages: string[] = []
const realDebugModule = await import(
`../../utils/debug.js?real=${Date.now()}-${Math.random()}`,
)
mock.module('../../utils/debug.js', () => ({
...realDebugModule,
logForDebugging: mock((message: string) => {
debugMessages.push(message)
}),
}))
// Other tests mock slowOperations process-wide; restore the real serializer so
// diagnostic keys keep message/range/code entropy under full-suite ordering.
mock.module('../../utils/slowOperations.js', () => ({
jsonStringify: JSON.stringify,
}))
const registry = await import(
`./LSPDiagnosticRegistry.ts?test=${Date.now()}-${Math.random()}`
)
function diagnostic(message: string, line = 0): Diagnostic {
return {
message,
severity: 'Error',
range: {
start: { line, character: 0 },
end: { line, character: 1 },
},
source: 'typescript',
code: `TS${line}`,
}
}
function diagnosticFile(uri: string, messages: string[]): DiagnosticFile {
return {
uri,
diagnostics: messages.map((message, index) => diagnostic(message, index)),
}
}
function diagnosticCount(files: DiagnosticFile[]): number {
return files.reduce((sum, file) => sum + file.diagnostics.length, 0)
}
describe('LSPDiagnosticRegistry storm control', () => {
beforeEach(() => {
registry.resetAllLSPDiagnosticState()
debugMessages.length = 0
})
test('dedupes repeated identical diagnostics before delivery', () => {
const repeated = diagnostic('same missing import')
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [{ uri: '/repo/a.ts', diagnostics: [repeated, repeated] }],
})
const diagnosticSets = registry.checkForLSPDiagnostics()
expect(diagnosticSets).toHaveLength(1)
expect(diagnosticSets[0]?.files).toEqual([
{ uri: '/repo/a.ts', diagnostics: [repeated] },
])
})
test('does not reattach unchanged diagnostics across turns', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const firstDiagnosticSets = registry.checkForLSPDiagnostics()
expect(firstDiagnosticSets).toHaveLength(1)
expect(diagnosticCount(firstDiagnosticSets[0]!.files)).toBe(1)
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
expect(registry.checkForLSPDiagnostics()).toEqual([])
})
test('allows edited files to resend diagnostics when cleared by file URI', () => {
const file = diagnosticFile('/repo/a.ts', ['same missing import'])
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const firstDiagnosticSets = registry.checkForLSPDiagnostics()
expect(firstDiagnosticSets).toHaveLength(1)
expect(diagnosticCount(firstDiagnosticSets[0]!.files)).toBe(1)
// Intentionally clear by file:// URI while diagnostics use a plain path;
// both forms must normalize to the same delivered-diagnostic key.
registry.clearDeliveredDiagnosticsForFile('file:///repo/a.ts')
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [file],
})
const secondDiagnosticSets = registry.checkForLSPDiagnostics()
expect(secondDiagnosticSets).toHaveLength(1)
expect(diagnosticCount(secondDiagnosticSets[0]!.files)).toBe(1)
})
test('enforces per-file and per-turn diagnostic caps', () => {
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [
diagnosticFile(
'/repo/crowded.ts',
Array.from({ length: 12 }, (_, index) => `crowded ${index}`),
),
...Array.from({ length: 25 }, (_, index) =>
diagnosticFile(`/repo/file-${index}.ts`, [`other ${index}`]),
),
],
})
const files = registry.checkForLSPDiagnostics()[0]?.files ?? []
expect(diagnosticCount(files)).toBe(30)
expect(
files.find(file => file.uri === '/repo/crowded.ts')?.diagnostics.length,
).toBe(10)
})
test('preserves recently active file diagnostics when total turn cap is exceeded', () => {
registry.recordLSPDiagnosticFileActivity('/repo/recent.ts')
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [
...Array.from({ length: 30 }, (_, index) =>
diagnosticFile(`/repo/old-${index}.ts`, [`old ${index}`]),
),
diagnosticFile('/repo/recent.ts', ['recent file should survive']),
],
})
const files = registry.checkForLSPDiagnostics()[0]?.files ?? []
expect(diagnosticCount(files)).toBe(30)
expect(files.some(file => file.uri === '/repo/recent.ts')).toBe(true)
})
test('emits one compact storm summary with rolling top files and no diagnostic text', () => {
const firstStormFile = diagnosticFile(
'/home/alice/project/src/noisy-a.ts',
Array.from(
{ length: 120 },
(_, index) => `do not leak raw diagnostic text A ${index}`,
),
)
const secondStormFile = diagnosticFile(
'/home/alice/project/src/noisy-b.ts',
Array.from(
{ length: 90 },
(_, index) => `do not leak raw diagnostic text B ${index}`,
),
)
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [firstStormFile, secondStormFile],
})
const files = registry.checkForLSPDiagnostics()[0]?.files ?? []
const stormSummary = files.find(file =>
file.uri.startsWith('lsp://diagnostic-storm/typescript'),
)
const stormLogs = debugMessages.filter(message =>
message.startsWith('LSP diagnostic storm: server=typescript'),
)
expect(diagnosticCount(files)).toBeLessThanOrEqual(30)
expect(stormSummary?.diagnostics).toHaveLength(1)
expect(stormSummary?.diagnostics[0]?.message).toContain('raw=210')
expect(stormSummary?.diagnostics[0]?.message).toContain('dropped=')
expect(stormSummary?.diagnostics[0]?.message).toContain('delivered=')
expect(stormSummary?.diagnostics[0]?.message).toContain(
'topFiles=[noisy-a.ts:120, noisy-b.ts:90]',
)
expect(stormSummary?.diagnostics[0]?.message).not.toContain(
'do not leak raw diagnostic text',
)
expect(stormLogs).toHaveLength(1)
})
test('does not trickle capped storm diagnostics into later turns', () => {
const stormFile = diagnosticFile(
'/repo/noisy.ts',
Array.from({ length: 210 }, (_, index) => `storm diagnostic ${index}`),
)
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [stormFile],
})
const firstFiles = registry.checkForLSPDiagnostics()[0]?.files ?? []
const firstRegularFile = firstFiles.find(file => file.uri === stormFile.uri)
expect(firstRegularFile?.diagnostics.map(diag => diag.code)).toEqual(
Array.from({ length: 10 }, (_, index) => `TS${index}`),
)
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: [stormFile],
})
const secondFiles = registry.checkForLSPDiagnostics()[0]?.files ?? []
expect(secondFiles.map(file => file.uri)).toEqual([
'lsp://diagnostic-storm/typescript',
])
})
test('reserves compact summaries for multiple storming servers before full diagnostics', () => {
registry.registerPendingLSPDiagnostic({
serverName: 'typescript',
files: Array.from({ length: 220 }, (_, index) =>
diagnosticFile(`/repo/typescript-${index}.ts`, [
`typescript storm ${index}`,
]),
),
})
registry.registerPendingLSPDiagnostic({
serverName: 'eslint',
files: [
diagnosticFile(
'/repo/eslint.ts',
Array.from({ length: 220 }, (_, index) => `eslint storm ${index}`),
),
],
})
const files = registry.checkForLSPDiagnostics()[0]?.files ?? []
const summaryUris = files
.filter(file => file.uri.startsWith('lsp://diagnostic-storm/'))
.map(file => file.uri)
expect(diagnosticCount(files)).toBeLessThanOrEqual(30)
expect(summaryUris).toContain('lsp://diagnostic-storm/typescript')
expect(summaryUris).toContain('lsp://diagnostic-storm/eslint')
})
})
+526 -117
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'crypto'
import { LRUCache } from 'lru-cache'
import * as path from 'path'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
@@ -44,6 +45,54 @@ const MAX_TOTAL_DIAGNOSTICS = 30
// Max files to track for deduplication - prevents unbounded memory growth
const MAX_DELIVERED_FILES = 500
const MAX_RECENT_FILES = 500
const DIAGNOSTIC_STORM_WINDOW_MS = 60_000
const DIAGNOSTIC_STORM_RAW_THRESHOLD = 200
const DIAGNOSTIC_STORM_LOG_THROTTLE_MS = 60_000
const RECENT_FILE_PRIORITY_WINDOW_MS = 5 * 60_000
const MAX_STORM_TOP_FILES = 5
const STORM_SUMMARY_URI_PREFIX = 'lsp://diagnostic-storm'
type DiagnosticWindowEvent = {
timestamp: number
rawCount: number
duplicateCount: number
droppedCount: number
deliveredCount: number
fileCounts: Map<string, number>
}
type DiagnosticWindowState = {
events: DiagnosticWindowEvent[]
lastStormSummaryLoggedAt: number | undefined
}
type RollingDiagnosticStats = {
rawCount: number
duplicateCount: number
droppedCount: number
deliveredCount: number
topFiles: Array<{ uri: string; count: number }>
}
type DeduplicationResult = {
files: DiagnosticFile[]
duplicateCount: number
}
type LimitResult = {
files: DiagnosticFile[]
droppedCount: number
deliveredCount: number
}
type ServerDeliveryPlan = {
serverName: string
deduplicationResult: DeduplicationResult
prioritizedFiles: DiagnosticFile[]
shouldSummarizeStorm: boolean
}
// Global registry state
const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>()
@@ -55,6 +104,239 @@ const deliveredDiagnostics = new LRUCache<string, Set<string>>({
max: MAX_DELIVERED_FILES,
})
const recentDiagnosticFileActivity = new LRUCache<string, number>({
max: MAX_RECENT_FILES,
})
const diagnosticWindows = new Map<string, DiagnosticWindowState>()
function normalizeDiagnosticUri(uri: string): string {
for (const prefix of ['file://', '_claude_fs_right:', '_claude_fs_left:']) {
if (uri.startsWith(prefix)) {
return uri.slice(prefix.length)
}
}
return uri
}
function displayFileForStormSummary(uri: string): string {
const normalized = normalizeDiagnosticUri(uri).replace(/\\/g, '/')
return path.basename(normalized) || normalized || '<unknown>'
}
function getDiagnosticWindowState(serverName: string): DiagnosticWindowState {
let state = diagnosticWindows.get(serverName)
if (!state) {
state = { events: [], lastStormSummaryLoggedAt: undefined }
diagnosticWindows.set(serverName, state)
}
return state
}
function pruneDiagnosticWindow(serverName: string, now: number): void {
const state = diagnosticWindows.get(serverName)
if (!state) {
return
}
state.events = state.events.filter(
event => now - event.timestamp <= DIAGNOSTIC_STORM_WINDOW_MS,
)
if (
state.events.length === 0 &&
(state.lastStormSummaryLoggedAt === undefined ||
now - state.lastStormSummaryLoggedAt > DIAGNOSTIC_STORM_LOG_THROTTLE_MS)
) {
diagnosticWindows.delete(serverName)
}
}
function recordDiagnosticWindowEvent(
serverName: string,
event: DiagnosticWindowEvent,
): void {
const state = getDiagnosticWindowState(serverName)
state.events.push(event)
pruneDiagnosticWindow(serverName, event.timestamp)
}
function recordDiagnosticsReceived(
serverName: string,
files: DiagnosticFile[],
now: number,
): void {
const fileCounts = new Map<string, number>()
let rawCount = 0
for (const file of files) {
const count = file.diagnostics.length
if (count === 0) {
continue
}
rawCount += count
const normalizedUri = normalizeDiagnosticUri(file.uri)
fileCounts.set(normalizedUri, (fileCounts.get(normalizedUri) ?? 0) + count)
}
if (rawCount === 0) {
return
}
recordDiagnosticWindowEvent(serverName, {
timestamp: now,
rawCount,
duplicateCount: 0,
droppedCount: 0,
deliveredCount: 0,
fileCounts,
})
}
function recordDiagnosticsDelivery(
serverName: string,
stats: {
duplicateCount: number
droppedCount: number
deliveredCount: number
},
now: number,
): void {
if (
stats.duplicateCount === 0 &&
stats.droppedCount === 0 &&
stats.deliveredCount === 0
) {
return
}
recordDiagnosticWindowEvent(serverName, {
timestamp: now,
rawCount: 0,
duplicateCount: stats.duplicateCount,
droppedCount: stats.droppedCount,
deliveredCount: stats.deliveredCount,
fileCounts: new Map(),
})
}
function getRollingDiagnosticStats(
serverName: string,
now: number,
): RollingDiagnosticStats {
pruneDiagnosticWindow(serverName, now)
const state = diagnosticWindows.get(serverName)
const fileCounts = new Map<string, number>()
const stats: RollingDiagnosticStats = {
rawCount: 0,
duplicateCount: 0,
droppedCount: 0,
deliveredCount: 0,
topFiles: [],
}
if (!state) {
return stats
}
for (const event of state.events) {
stats.rawCount += event.rawCount
stats.duplicateCount += event.duplicateCount
stats.droppedCount += event.droppedCount
stats.deliveredCount += event.deliveredCount
for (const [uri, count] of event.fileCounts) {
fileCounts.set(uri, (fileCounts.get(uri) ?? 0) + count)
}
}
stats.topFiles = Array.from(fileCounts.entries())
.map(([uri, count]) => ({ uri, count }))
.sort((a, b) => {
if (b.count !== a.count) {
return b.count - a.count
}
return displayFileForStormSummary(a.uri).localeCompare(
displayFileForStormSummary(b.uri),
)
})
.slice(0, MAX_STORM_TOP_FILES)
return stats
}
function shouldAttachStormSummary(stats: RollingDiagnosticStats): boolean {
return stats.rawCount > DIAGNOSTIC_STORM_RAW_THRESHOLD
}
function formatStormSummary(
serverName: string,
stats: RollingDiagnosticStats,
): string {
const topFiles =
stats.topFiles.length === 0
? 'none'
: stats.topFiles
.map(file => `${displayFileForStormSummary(file.uri)}:${file.count}`)
.join(', ')
return (
`LSP diagnostic storm: server=${serverName} ` +
`raw=${stats.rawCount} duplicates=${stats.duplicateCount} ` +
`dropped=${stats.droppedCount} delivered=${stats.deliveredCount} ` +
`topFiles=[${topFiles}]`
)
}
function buildStormSummaryFile(
serverName: string,
stats: RollingDiagnosticStats,
): DiagnosticFile {
return {
uri: `${STORM_SUMMARY_URI_PREFIX}/${encodeURIComponent(serverName)}`,
diagnostics: [
{
message: formatStormSummary(serverName, stats),
severity: 'Info',
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
source: 'openclaude-lsp',
code: 'diagnostic-storm',
},
],
}
}
function maybeLogStormSummary(
serverName: string,
stats: RollingDiagnosticStats,
now: number,
): void {
const state = getDiagnosticWindowState(serverName)
if (
state.lastStormSummaryLoggedAt !== undefined &&
now - state.lastStormSummaryLoggedAt < DIAGNOSTIC_STORM_LOG_THROTTLE_MS
) {
return
}
state.lastStormSummaryLoggedAt = now
logForDebugging(formatStormSummary(serverName, stats))
}
/**
* Record an LSP file interaction so diagnostics for recently opened or edited
* files are preserved first when a diagnostic burst exceeds the per-turn cap.
*/
export function recordLSPDiagnosticFileActivity(
fileUri: string,
timestamp = Date.now(),
): void {
recentDiagnosticFileActivity.set(normalizeDiagnosticUri(fileUri), timestamp)
}
/**
* Register LSP diagnostics received from a server.
* These will be delivered as attachments in the next query.
@@ -65,21 +347,21 @@ const deliveredDiagnostics = new LRUCache<string, Set<string>>({
export function registerPendingLSPDiagnostic({
serverName,
files,
timestamp = Date.now(),
}: {
serverName: string
files: DiagnosticFile[]
timestamp?: number
}): void {
// Use UUID for guaranteed uniqueness (handles rapid registrations)
const diagnosticId = randomUUID()
logForDebugging(
`LSP Diagnostics: Registering ${files.length} diagnostic file(s) from ${serverName} (ID: ${diagnosticId})`,
)
recordDiagnosticsReceived(serverName, files, timestamp)
pendingDiagnostics.set(diagnosticId, {
serverName,
files,
timestamp: Date.now(),
timestamp,
attachmentSent: false,
})
}
@@ -135,22 +417,28 @@ function createDiagnosticKey(diag: {
*/
function deduplicateDiagnosticFiles(
allFiles: DiagnosticFile[],
): DiagnosticFile[] {
): DeduplicationResult {
// Group diagnostics by file URI
const fileMap = new Map<string, Set<string>>()
const dedupedFileMap = new Map<string, DiagnosticFile>()
const dedupedFiles: DiagnosticFile[] = []
let duplicateCount = 0
for (const file of allFiles) {
if (!fileMap.has(file.uri)) {
fileMap.set(file.uri, new Set())
dedupedFiles.push({ uri: file.uri, diagnostics: [] })
const normalizedUri = normalizeDiagnosticUri(file.uri)
if (!fileMap.has(normalizedUri)) {
fileMap.set(normalizedUri, new Set())
const dedupedFile = { uri: file.uri, diagnostics: [] }
dedupedFileMap.set(normalizedUri, dedupedFile)
dedupedFiles.push(dedupedFile)
}
const seenDiagnostics = fileMap.get(file.uri)!
const dedupedFile = dedupedFiles.find(f => f.uri === file.uri)!
const seenDiagnostics = fileMap.get(normalizedUri)!
const dedupedFile = dedupedFileMap.get(normalizedUri)!
// Get previously delivered diagnostics for this file (for cross-turn dedup)
const previouslyDelivered = deliveredDiagnostics.get(file.uri) || new Set()
const previouslyDelivered =
deliveredDiagnostics.get(normalizedUri) || new Set()
for (const diag of file.diagnostics) {
try {
@@ -158,6 +446,7 @@ function deduplicateDiagnosticFiles(
// Skip if already seen in this batch OR already delivered in previous turns
if (seenDiagnostics.has(key) || previouslyDelivered.has(key)) {
duplicateCount++
continue
}
@@ -180,119 +469,87 @@ function deduplicateDiagnosticFiles(
}
// Filter out files with no diagnostics after deduplication
return dedupedFiles.filter(f => f.diagnostics.length > 0)
return {
files: dedupedFiles.filter(f => f.diagnostics.length > 0),
duplicateCount,
}
}
/**
* Get all pending LSP diagnostics that haven't been delivered yet.
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
* Marks diagnostics as sent to prevent duplicate delivery.
*
* @returns Array of pending diagnostics ready for delivery (deduplicated)
*/
export function checkForLSPDiagnostics(): Array<{
serverName: string
files: DiagnosticFile[]
}> {
logForDebugging(
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
)
function prioritizeDiagnosticFiles(
files: DiagnosticFile[],
now: number,
): DiagnosticFile[] {
return files
.map((file, index) => {
const lastActivity = recentDiagnosticFileActivity.get(
normalizeDiagnosticUri(file.uri),
)
const isRecent =
lastActivity !== undefined &&
now - lastActivity <= RECENT_FILE_PRIORITY_WINDOW_MS
// Collect all diagnostic files from all pending notifications
const allFiles: DiagnosticFile[] = []
const serverNames = new Set<string>()
const diagnosticsToMark: PendingLSPDiagnostic[] = []
return { file, index, isRecent, lastActivity: lastActivity ?? 0 }
})
.sort((a, b) => {
if (a.isRecent !== b.isRecent) {
return a.isRecent ? -1 : 1
}
if (a.isRecent && b.isRecent && a.lastActivity !== b.lastActivity) {
return b.lastActivity - a.lastActivity
}
return a.index - b.index
})
.map(item => item.file)
}
for (const diagnostic of pendingDiagnostics.values()) {
if (!diagnostic.attachmentSent) {
allFiles.push(...diagnostic.files)
serverNames.add(diagnostic.serverName)
diagnosticsToMark.push(diagnostic)
}
}
function limitDiagnosticFiles(
files: DiagnosticFile[],
capacity: number,
): LimitResult {
const limitedFiles: DiagnosticFile[] = []
let remainingCapacity = Math.max(0, capacity)
let droppedCount = 0
let deliveredCount = 0
if (allFiles.length === 0) {
return []
}
// Deduplicate diagnostics across all files
let dedupedFiles: DiagnosticFile[]
try {
dedupedFiles = deduplicateDiagnosticFiles(allFiles)
} catch (error: unknown) {
const err = toError(error)
logError(new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`))
// Fall back to undedup'd files to avoid losing diagnostics
dedupedFiles = allFiles
}
// Only mark as sent AFTER successful deduplication, then delete from map.
// Entries are tracked in deliveredDiagnostics LRU for dedup, so we don't
// need to keep them in pendingDiagnostics after delivery.
for (const diagnostic of diagnosticsToMark) {
diagnostic.attachmentSent = true
}
for (const [id, diagnostic] of pendingDiagnostics) {
if (diagnostic.attachmentSent) {
pendingDiagnostics.delete(id)
}
}
const originalCount = allFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
)
const dedupedCount = dedupedFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
)
if (originalCount > dedupedCount) {
logForDebugging(
`LSP Diagnostics: Deduplication removed ${originalCount - dedupedCount} duplicate diagnostic(s)`,
)
}
// Apply volume limiting: cap per file and total
let totalDiagnostics = 0
let truncatedCount = 0
for (const file of dedupedFiles) {
// Sort by severity (Error=1 < Warning=2 < Info=3 < Hint=4) to prioritize errors
file.diagnostics.sort(
for (const file of files) {
const sortedDiagnostics = [...file.diagnostics].sort(
(a, b) => severityToNumber(a.severity) - severityToNumber(b.severity),
)
// Cap per file
if (file.diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) {
truncatedCount += file.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE
file.diagnostics = file.diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE)
let diagnostics = sortedDiagnostics
if (diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) {
droppedCount += diagnostics.length - MAX_DIAGNOSTICS_PER_FILE
diagnostics = diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE)
}
// Cap total
const remainingCapacity = MAX_TOTAL_DIAGNOSTICS - totalDiagnostics
if (file.diagnostics.length > remainingCapacity) {
truncatedCount += file.diagnostics.length - remainingCapacity
file.diagnostics = file.diagnostics.slice(0, remainingCapacity)
if (remainingCapacity <= 0) {
droppedCount += diagnostics.length
continue
}
totalDiagnostics += file.diagnostics.length
if (diagnostics.length > remainingCapacity) {
droppedCount += diagnostics.length - remainingCapacity
diagnostics = diagnostics.slice(0, remainingCapacity)
}
remainingCapacity -= diagnostics.length
deliveredCount += diagnostics.length
if (diagnostics.length > 0) {
limitedFiles.push({ uri: file.uri, diagnostics })
}
}
// Filter out files that ended up with no diagnostics after limiting
dedupedFiles = dedupedFiles.filter(f => f.diagnostics.length > 0)
return { files: limitedFiles, droppedCount, deliveredCount }
}
if (truncatedCount > 0) {
logForDebugging(
`LSP Diagnostics: Volume limiting removed ${truncatedCount} diagnostic(s) (max ${MAX_DIAGNOSTICS_PER_FILE}/file, ${MAX_TOTAL_DIAGNOSTICS} total)`,
)
}
// Track delivered diagnostics for cross-turn deduplication
for (const file of dedupedFiles) {
if (!deliveredDiagnostics.has(file.uri)) {
deliveredDiagnostics.set(file.uri, new Set())
function trackDeliveredDiagnostics(files: DiagnosticFile[]): void {
for (const file of files) {
const normalizedUri = normalizeDiagnosticUri(file.uri)
if (!deliveredDiagnostics.has(normalizedUri)) {
deliveredDiagnostics.set(normalizedUri, new Set())
}
const delivered = deliveredDiagnostics.get(file.uri)!
const delivered = deliveredDiagnostics.get(normalizedUri)!
for (const diag of file.diagnostics) {
try {
delivered.add(createDiagnosticKey(diag))
@@ -310,29 +567,178 @@ export function checkForLSPDiagnostics(): Array<{
}
}
}
}
const finalCount = dedupedFiles.reduce(
(sum, f) => sum + f.diagnostics.length,
0,
/**
* Get all pending LSP diagnostics that haven't been delivered yet.
* Deduplicates diagnostics to prevent sending the same diagnostic multiple times.
* Marks diagnostics as sent to prevent duplicate delivery.
*
* @returns Array of pending diagnostics ready for delivery (deduplicated)
*/
export function checkForLSPDiagnostics(): Array<{
serverName: string
files: DiagnosticFile[]
}> {
const now = Date.now()
logForDebugging(
`LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`,
)
// Collect pending diagnostic files by server so storm stats remain per-server.
const filesByServer = new Map<string, DiagnosticFile[]>()
const diagnosticsToMark: PendingLSPDiagnostic[] = []
for (const diagnostic of pendingDiagnostics.values()) {
if (!diagnostic.attachmentSent) {
if (!filesByServer.has(diagnostic.serverName)) {
filesByServer.set(diagnostic.serverName, [])
}
filesByServer.get(diagnostic.serverName)!.push(...diagnostic.files)
diagnosticsToMark.push(diagnostic)
}
}
if (filesByServer.size === 0) {
return []
}
// Only mark as sent AFTER successful deduplication, then delete from map.
// Entries are tracked in deliveredDiagnostics LRU for dedup, so we don't
// need to keep them in pendingDiagnostics after delivery.
for (const diagnostic of diagnosticsToMark) {
diagnostic.attachmentSent = true
}
for (const [id, diagnostic] of pendingDiagnostics) {
if (diagnostic.attachmentSent) {
pendingDiagnostics.delete(id)
}
}
const serverNames: string[] = []
const deliveredFiles: DiagnosticFile[] = []
let duplicateCount = 0
let droppedCount = 0
let deliveredCount = 0
const deliveryPlans: ServerDeliveryPlan[] = []
for (const [serverName, files] of filesByServer) {
let deduplicationResult: DeduplicationResult
try {
deduplicationResult = deduplicateDiagnosticFiles(files)
} catch (error: unknown) {
const err = toError(error)
logError(
new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`),
)
// Fall back to undedup'd files to avoid losing diagnostics.
deduplicationResult = { files, duplicateCount: 0 }
}
const statsBeforeDelivery = getRollingDiagnosticStats(serverName, now)
const shouldSummarizeStorm = shouldAttachStormSummary(statsBeforeDelivery)
const prioritizedFiles = prioritizeDiagnosticFiles(
deduplicationResult.files,
now,
)
deliveryPlans.push({
serverName,
deduplicationResult,
prioritizedFiles,
shouldSummarizeStorm,
})
}
const reservedStormSummaryCount = Math.min(
MAX_TOTAL_DIAGNOSTICS,
deliveryPlans.filter(plan => plan.shouldSummarizeStorm).length,
)
let remainingCapacity = MAX_TOTAL_DIAGNOSTICS - reservedStormSummaryCount
let remainingStormSummarySlots = reservedStormSummaryCount
// The total diagnostic cap is global for the turn. Reserve compact storm
// summary slots before allocating full diagnostics so one server cannot hide
// another storming server's summary by exhausting the payload budget first.
for (const plan of deliveryPlans) {
const {
serverName,
deduplicationResult,
prioritizedFiles,
shouldSummarizeStorm,
} = plan
serverNames.push(serverName)
const summarySlotReserved =
shouldSummarizeStorm && remainingStormSummarySlots > 0
if (summarySlotReserved) {
remainingStormSummarySlots--
}
const limitResult = limitDiagnosticFiles(prioritizedFiles, remainingCapacity)
recordDiagnosticsDelivery(
serverName,
{
duplicateCount: deduplicationResult.duplicateCount,
droppedCount: limitResult.droppedCount,
deliveredCount: limitResult.deliveredCount,
},
now,
)
const statsAfterDelivery = getRollingDiagnosticStats(serverName, now)
if (summarySlotReserved) {
deliveredFiles.push(buildStormSummaryFile(serverName, statsAfterDelivery))
maybeLogStormSummary(serverName, statsAfterDelivery, now)
}
// Volume caps intentionally drop diagnostics for the turn; account for the
// full deduplicated batch so unchanged storms cannot trickle old diagnostics
// into later turns one capped slice at a time.
trackDeliveredDiagnostics(deduplicationResult.files)
deliveredFiles.push(...limitResult.files)
remainingCapacity -= limitResult.deliveredCount
duplicateCount += deduplicationResult.duplicateCount
droppedCount += limitResult.droppedCount
deliveredCount += limitResult.deliveredCount
if (remainingCapacity <= 0 && serverNames.length < filesByServer.size) {
logForDebugging(
`LSP Diagnostics: Global turn capacity exhausted after ${serverName}; later server diagnostics will be summarized or dropped`,
)
}
}
// Return empty if no diagnostics to deliver (all filtered by deduplication)
if (finalCount === 0) {
if (deliveredFiles.length === 0) {
logForDebugging(
`LSP Diagnostics: No new diagnostics to deliver (all filtered by deduplication)`,
)
return []
}
if (duplicateCount > 0) {
logForDebugging(
`LSP Diagnostics: Deduplication removed ${duplicateCount} duplicate diagnostic(s)`,
)
}
if (droppedCount > 0) {
logForDebugging(
`LSP Diagnostics: Volume limiting removed ${droppedCount} diagnostic(s) (max ${MAX_DIAGNOSTICS_PER_FILE}/file, ${MAX_TOTAL_DIAGNOSTICS} total)`,
)
}
logForDebugging(
`LSP Diagnostics: Delivering ${dedupedFiles.length} file(s) with ${finalCount} diagnostic(s) from ${serverNames.size} server(s)`,
`LSP Diagnostics: Delivering ${deliveredFiles.length} file(s) with ${deliveredCount} diagnostic(s) from ${serverNames.length} server(s)`,
)
// Return single result with all deduplicated diagnostics
return [
{
serverName: Array.from(serverNames).join(', '),
files: dedupedFiles,
serverName: serverNames.join(', '),
files: deliveredFiles,
},
]
}
@@ -360,6 +766,8 @@ export function resetAllLSPDiagnosticState(): void {
)
pendingDiagnostics.clear()
deliveredDiagnostics.clear()
recentDiagnosticFileActivity.clear()
diagnosticWindows.clear()
}
/**
@@ -370,11 +778,12 @@ export function resetAllLSPDiagnosticState(): void {
* @param fileUri - URI of the file that was edited
*/
export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
if (deliveredDiagnostics.has(fileUri)) {
const normalizedUri = normalizeDiagnosticUri(fileUri)
if (deliveredDiagnostics.has(normalizedUri)) {
logForDebugging(
`LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`,
)
deliveredDiagnostics.delete(fileUri)
deliveredDiagnostics.delete(normalizedUri)
}
}
+12 -3
View File
@@ -4,6 +4,7 @@ import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getAllLspServers } from './config.js'
import { recordLSPDiagnosticFileActivity } from './LSPDiagnosticRegistry.js'
import {
createLSPServerInstance,
type LSPServerInstance,
@@ -271,7 +272,9 @@ export function createLSPServerManager(): LSPServerManager {
const server = await ensureServerStarted(filePath)
if (!server) return
const fileUri = pathToFileURL(path.resolve(filePath)).href
const resolvedFilePath = path.resolve(filePath)
const fileUri = pathToFileURL(resolvedFilePath).href
recordLSPDiagnosticFileActivity(resolvedFilePath)
// Skip if already opened on this server
if (openedFiles.get(fileUri) === server.name) {
@@ -315,7 +318,8 @@ export function createLSPServerManager(): LSPServerManager {
return openFile(filePath, content)
}
const fileUri = pathToFileURL(path.resolve(filePath)).href
const resolvedFilePath = path.resolve(filePath)
const fileUri = pathToFileURL(resolvedFilePath).href
// If file hasn't been opened on this server yet, open it first
// LSP servers require didOpen before didChange
@@ -323,6 +327,8 @@ export function createLSPServerManager(): LSPServerManager {
return openFile(filePath, content)
}
recordLSPDiagnosticFileActivity(resolvedFilePath)
try {
await server.sendNotification('textDocument/didChange', {
textDocument: {
@@ -350,10 +356,13 @@ export function createLSPServerManager(): LSPServerManager {
const server = getServerForFile(filePath)
if (!server || server.state !== 'running') return
const resolvedFilePath = path.resolve(filePath)
recordLSPDiagnosticFileActivity(resolvedFilePath)
try {
await server.sendNotification('textDocument/didSave', {
textDocument: {
uri: pathToFileURL(path.resolve(filePath)).href,
uri: pathToFileURL(resolvedFilePath).href,
},
})
logForDebugging(`LSP: Sent didSave for ${filePath}`)
-10
View File
@@ -161,9 +161,6 @@ export function registerLSPNotificationHandlers(
serverInstance.onNotification(
'textDocument/publishDiagnostics',
(params: unknown) => {
logForDebugging(
`[PASSIVE DIAGNOSTICS] Handler invoked for ${serverName}! Params type: ${typeof params}`,
)
try {
// Validate params structure before casting
if (
@@ -183,9 +180,6 @@ export function registerLSPNotificationHandlers(
}
const diagnosticParams = params as PublishDiagnosticsParams
logForDebugging(
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
)
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
const diagnosticFiles =
@@ -212,10 +206,6 @@ export function registerLSPNotificationHandlers(
files: diagnosticFiles,
})
logForDebugging(
`LSP Diagnostics: Registered ${diagnosticFiles.length} diagnostic file(s) from ${serverName} for async delivery`,
)
// Success - reset failure counter for this server
diagnosticFailures.delete(serverName)
} catch (error) {