diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 685ebc58948..e8e96696e52 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -184,6 +184,13 @@ export function registerChatOpenAgentDebugPanelAction() { return; } + const maxImportSize = 50 * 1024 * 1024; // 50 MB + const stat = await fileService.stat(result[0]); + if (stat.size !== undefined && stat.size > maxImportSize) { + notificationService.notify({ severity: Severity.Warning, message: localize('chatDebugLog.fileTooLarge', "The selected file exceeds the 50 MB size limit for log imports.") }); + return; + } + const content = await fileService.readFile(result[0]); const sessionUri = await chatDebugService.importLog(content.value.buffer); if (!sessionUri) { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts index b6057612820..cc143f09377 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -9,6 +9,7 @@ import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/br import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -86,6 +87,7 @@ export class ChatDebugFlowChartView extends Disposable { // Detail panel private readonly detailPanel: ChatDebugDetailPanel; private eventById = new Map(); + private readonly refreshScheduler: RunOnceScheduler; constructor( parent: HTMLElement, @@ -158,6 +160,8 @@ export class ChatDebugFlowChartView extends Disposable { // Set up pan/zoom event listeners and keyboard handling this.setupPanZoom(); this.setupKeyboard(); + + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.load(), 100)); } setSession(sessionResource: URI): void { @@ -184,11 +188,14 @@ export class ChatDebugFlowChartView extends Disposable { hide(): void { DOM.hide(this.container); + this.refreshScheduler.cancel(); } refresh(): void { if (this.container.style.display !== 'none') { - this.load(); + if (!this.refreshScheduler.isScheduled()) { + this.refreshScheduler.schedule(); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts index fc5ce77add7..8d9fcbdd2aa 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -6,6 +6,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; @@ -42,6 +43,7 @@ export class ChatDebugOverviewView extends Disposable { private currentSessionResource: URI | undefined; private metricsContainer: HTMLElement | undefined; private isFirstLoad: boolean = true; + private readonly refreshScheduler: RunOnceScheduler; constructor( parent: HTMLElement, @@ -54,6 +56,8 @@ export class ChatDebugOverviewView extends Disposable { this.container = DOM.append(parent, $('.chat-debug-overview')); DOM.hide(this.container); + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.doRefresh(), 100)); + // Breadcrumb const breadcrumbContainer = DOM.append(this.container, $('.chat-debug-breadcrumb')); this.breadcrumbWidget = this._register(new BreadcrumbsWidget(breadcrumbContainer, 3, undefined, Codicon.chevronRight, defaultBreadcrumbsWidgetStyles)); @@ -84,22 +88,29 @@ export class ChatDebugOverviewView extends Disposable { hide(): void { DOM.hide(this.container); + this.refreshScheduler.cancel(); } refresh(): void { if (this.container.style.display !== 'none') { - // On refresh, only update the metrics section in-place - if (this.metricsContainer && this.currentSessionResource) { - DOM.clearNode(this.metricsContainer); - const events = this.chatDebugService.getEvents(this.currentSessionResource); - this.renderMetricsContent(this.metricsContainer, events); - this.isFirstLoad = false; - } else { - this.load(); + if (!this.refreshScheduler.isScheduled()) { + this.refreshScheduler.schedule(); } } } + private doRefresh(): void { + // On refresh, only update the metrics section in-place + if (this.metricsContainer && this.currentSessionResource) { + DOM.clearNode(this.metricsContainer); + const events = this.chatDebugService.getEvents(this.currentSessionResource); + this.renderMetricsContent(this.metricsContainer, events); + this.isFirstLoad = false; + } else { + this.load(); + } + } + updateBreadcrumb(): void { if (!this.currentSessionResource) { return; diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index c80186d968d..4279479c130 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -3,24 +3,94 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { timeout } from '../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; +import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugLogProvider, IChatDebugResolvedEventContent, IChatDebugService } from './chatDebugService.js'; import { LocalChatSessionUri } from './model/chatUri.js'; +/** + * Per-session circular buffer for debug events. + * Stores up to `capacity` events using a ring buffer. + */ +class SessionEventBuffer { + private readonly _buffer: (IChatDebugEvent | undefined)[]; + private _head = 0; + private _size = 0; + + constructor(readonly capacity: number) { + this._buffer = new Array(capacity); + } + + get size(): number { + return this._size; + } + + push(event: IChatDebugEvent): void { + const idx = (this._head + this._size) % this.capacity; + this._buffer[idx] = event; + if (this._size < this.capacity) { + this._size++; + } else { + this._head = (this._head + 1) % this.capacity; + } + } + + /** Return events in insertion order. */ + toArray(): IChatDebugEvent[] { + const result: IChatDebugEvent[] = []; + for (let i = 0; i < this._size; i++) { + const event = this._buffer[(this._head + i) % this.capacity]; + if (event) { + result.push(event); + } + } + return result; + } + + /** Remove events matching the predicate and compact in-place. */ + removeWhere(predicate: (event: IChatDebugEvent) => boolean): void { + let write = 0; + for (let i = 0; i < this._size; i++) { + const idx = (this._head + i) % this.capacity; + const event = this._buffer[idx]; + if (event && predicate(event)) { + continue; + } + if (write !== i) { + const writeIdx = (this._head + write) % this.capacity; + this._buffer[writeIdx] = event; + } + write++; + } + for (let i = write; i < this._size; i++) { + this._buffer[(this._head + i) % this.capacity] = undefined; + } + this._size = write; + } + + clear(): void { + this._buffer.fill(undefined); + this._head = 0; + this._size = 0; + } +} + export class ChatDebugServiceImpl extends Disposable implements IChatDebugService { declare readonly _serviceBrand: undefined; - private static readonly MAX_EVENTS = 10_000; + static readonly MAX_EVENTS_PER_SESSION = 10_000; + static readonly MAX_SESSIONS = 5; - // Circular buffer: fixed-size array with head/size tracking for O(1) append. - private readonly _buffer: (IChatDebugEvent | undefined)[] = new Array(ChatDebugServiceImpl.MAX_EVENTS); - private _head = 0; // index of the oldest element - private _size = 0; // number of elements currently stored + /** Per-session event buffers. Ordered from oldest to newest session (LRU). */ + private readonly _sessionBuffers = new ResourceMap(); + /** Ordered list of session URIs for LRU eviction. */ + private readonly _sessionOrder: URI[] = []; private readonly _onDidAddEvent = this._register(new Emitter()); readonly onDidAddEvent: Event = this._onDidAddEvent.event; @@ -65,13 +135,25 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } addEvent(event: IChatDebugEvent): void { - const idx = (this._head + this._size) % ChatDebugServiceImpl.MAX_EVENTS; - this._buffer[idx] = event; - if (this._size < ChatDebugServiceImpl.MAX_EVENTS) { - this._size++; + let buffer = this._sessionBuffers.get(event.sessionResource); + if (!buffer) { + // Evict least-recently-used session if we are at the session cap. + if (this._sessionOrder.length >= ChatDebugServiceImpl.MAX_SESSIONS) { + const evicted = this._sessionOrder.shift()!; + this._evictSession(evicted); + } + buffer = new SessionEventBuffer(ChatDebugServiceImpl.MAX_EVENTS_PER_SESSION); + this._sessionBuffers.set(event.sessionResource, buffer); + this._sessionOrder.push(event.sessionResource); } else { - this._head = (this._head + 1) % ChatDebugServiceImpl.MAX_EVENTS; + // Move to end of LRU order so actively-used sessions are not evicted. + const idx = this._sessionOrder.findIndex(u => extUri.isEqual(u, event.sessionResource)); + if (idx !== -1 && idx !== this._sessionOrder.length - 1) { + this._sessionOrder.splice(idx, 1); + this._sessionOrder.push(event.sessionResource); + } } + buffer.push(event); this._onDidAddEvent.fire(event); } @@ -81,15 +163,14 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } getEvents(sessionResource?: URI): readonly IChatDebugEvent[] { - const result: IChatDebugEvent[] = []; - const key = sessionResource?.toString(); - for (let i = 0; i < this._size; i++) { - const event = this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS]; - if (!event) { - continue; - } - if (!key || event.sessionResource.toString() === key) { - result.push(event); + let result: IChatDebugEvent[]; + if (sessionResource) { + const buffer = this._sessionBuffers.get(sessionResource); + result = buffer ? buffer.toArray() : []; + } else { + result = []; + for (const buffer of this._sessionBuffers.values()) { + result.push(...buffer.toArray()); } } result.sort((a, b) => a.created.getTime() - b.created.getTime()); @@ -97,26 +178,29 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } getSessionResources(): readonly URI[] { - const seen = new ResourceMap(); - const result: URI[] = []; - for (let i = 0; i < this._size; i++) { - const event = this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS]; - if (!event) { - continue; - } - if (!seen.has(event.sessionResource)) { - seen.set(event.sessionResource, true); - result.push(event.sessionResource); - } - } - return result; + return [...this._sessionOrder]; } clear(): void { - this._buffer.fill(undefined); - this._head = 0; - this._size = 0; + this._sessionBuffers.clear(); + this._sessionOrder.length = 0; this._debugDataAttachedSessions.clear(); + this._importedSessions.clear(); + this._importedSessionTitles.clear(); + } + + /** Remove all ancillary state for an evicted session. */ + private _evictSession(sessionResource: URI): void { + this._sessionBuffers.delete(sessionResource); + this._importedSessions.delete(sessionResource); + this._importedSessionTitles.delete(sessionResource); + this._debugDataAttachedSessions.delete(sessionResource); + const cts = this._invocationCts.get(sessionResource); + if (cts) { + cts.cancel(); + cts.dispose(); + this._invocationCts.delete(sessionResource); + } } registerProvider(provider: IChatDebugLogProvider): IDisposable { @@ -180,11 +264,21 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic try { const events = await provider.provideChatDebugLog(sessionResource, token); if (events) { - for (const event of events) { + // Yield to the event loop periodically so the UI stays + // responsive when a provider returns a large batch of events + // (e.g. importing a multi-MB log file). + const BATCH_SIZE = 500; + for (let i = 0; i < events.length; i++) { + if (token.isCancellationRequested) { + break; + } this.addProviderEvent({ - ...event, - sessionResource: event.sessionResource ?? sessionResource, + ...events[i], + sessionResource: events[i].sessionResource ?? sessionResource, }); + if (i > 0 && i % BATCH_SIZE === 0) { + await timeout(0); + } } } } catch (err) { @@ -203,26 +297,10 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic } private _clearProviderEvents(sessionResource: URI): void { - const key = sessionResource.toString(); - // Compact the ring buffer in-place, removing matching provider events. - let write = 0; - for (let i = 0; i < this._size; i++) { - const idx = (this._head + i) % ChatDebugServiceImpl.MAX_EVENTS; - const event = this._buffer[idx]; - if (event && this._providerEvents.has(event) && event.sessionResource.toString() === key) { - continue; // skip — this event is removed - } - if (write !== i) { - const writeIdx = (this._head + write) % ChatDebugServiceImpl.MAX_EVENTS; - this._buffer[writeIdx] = event; - } - write++; + const buffer = this._sessionBuffers.get(sessionResource); + if (buffer) { + buffer.removeWhere(event => this._providerEvents.has(event)); } - // Clear trailing slots and update size - for (let i = write; i < this._size; i++) { - this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS] = undefined; - } - this._size = write; this._onDidClearProviderEvents.fire(sessionResource); } @@ -304,6 +382,8 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic cts.dispose(); } this._invocationCts.clear(); + this.clear(); + this._providers.clear(); super.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 9b9b0aac42a..b3ca676d729 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -185,21 +185,61 @@ suite('ChatDebugServiceImpl', () => { }); }); - suite('MAX_EVENTS cap', () => { - test('should evict oldest events when exceeding cap', () => { - // The max is 10_000. Add more than that and verify trimming. - // We'll test with a smaller count by adding events and checking boundary behavior. + suite('MAX_EVENTS_PER_SESSION cap', () => { + test('should evict oldest events when exceeding per-session cap', () => { + // The max per session is 10_000. Add more than that to a single session. for (let i = 0; i < 10_001; i++) { service.addEvent({ kind: 'generic', sessionResource: sessionGeneric, created: new Date(), name: `event-${i}`, level: ChatDebugLogLevel.Info }); } const events = service.getEvents(); - assert.ok(events.length <= 10_000, 'Should not exceed MAX_EVENTS'); + assert.ok(events.length <= 10_000, 'Should not exceed MAX_EVENTS_PER_SESSION'); // The first event should have been evicted assert.ok(!(events as IChatDebugGenericEvent[]).find(e => e.name === 'event-0'), 'Event-0 should have been evicted'); // The last event should be present assert.ok((events as IChatDebugGenericEvent[]).find(e => e.name === 'event-10000'), 'Last event should be present'); }); + + test('should evict oldest session when exceeding MAX_SESSIONS', () => { + // MAX_SESSIONS is 5 — add events to 6 different sessions + const sessions: URI[] = []; + for (let i = 0; i < 6; i++) { + const uri = URI.parse(`vscode-chat-session://local/session-lru-${i}`); + sessions.push(uri); + service.addEvent({ kind: 'generic', sessionResource: uri, created: new Date(), name: `event-${i}`, level: ChatDebugLogLevel.Info }); + } + + const resources = service.getSessionResources(); + assert.strictEqual(resources.length, 5, 'Should not exceed MAX_SESSIONS'); + // The first session should have been evicted + assert.ok(!resources.some(r => r.toString() === sessions[0].toString()), 'Session-0 should have been evicted'); + assert.strictEqual(service.getEvents(sessions[0]).length, 0, 'Events from evicted session should be gone'); + // The last session should be present + assert.ok(resources.some(r => r.toString() === sessions[5].toString()), 'Session-5 should be present'); + }); + + test('should use LRU eviction — recently-used sessions are kept', () => { + // Fill to MAX_SESSIONS (5) + const sessions: URI[] = []; + for (let i = 0; i < 5; i++) { + const uri = URI.parse(`vscode-chat-session://local/session-lru2-${i}`); + sessions.push(uri); + service.addEvent({ kind: 'generic', sessionResource: uri, created: new Date(), name: `init-${i}`, level: ChatDebugLogLevel.Info }); + } + + // Touch session-0 so it moves to the back of the LRU order + service.addEvent({ kind: 'generic', sessionResource: sessions[0], created: new Date(), name: 'touch', level: ChatDebugLogLevel.Info }); + + // Add a 6th session — session-1 (the true LRU) should be evicted, not session-0 + const session6 = URI.parse('vscode-chat-session://local/session-lru2-5'); + service.addEvent({ kind: 'generic', sessionResource: session6, created: new Date(), name: 'new', level: ChatDebugLogLevel.Info }); + + const resources = service.getSessionResources(); + assert.strictEqual(resources.length, 5); + assert.ok(resources.some(r => r.toString() === sessions[0].toString()), 'Session-0 should be kept (recently used)'); + assert.ok(!resources.some(r => r.toString() === sessions[1].toString()), 'Session-1 should be evicted (LRU)'); + assert.ok(resources.some(r => r.toString() === session6.toString()), 'Session-5 should be present'); + }); }); suite('activeSessionResource', () => {