From 98f3db9a9415ba6a9ee2bb3a77aeced2589d9441 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sat, 30 May 2026 11:41:29 -0700 Subject: [PATCH 1/9] agents: fix duplicate workspace created tasks firing (#319078) The autorun in WorktreeCreatedTaskDispatcher previously disposed its own store via _sessionDisposables.deleteAndDispose, which could synchronously remove the entry while the autorun was still mid-run and allow a re-entrant _trackSession call to install a fresh autorun that fires again. - Switch to reader.dispose() so the autorun tears itself down cleanly without racing _sessionDisposables bookkeeping, preventing the worktreeCreated tasks from being dispatched more than once per session. (Commit message generated by Copilot) --- .../contrib/chat/browser/worktreeCreatedTaskDispatcher.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts index 49b76733456d7..13a4903735c85 100644 --- a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts +++ b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts @@ -72,9 +72,6 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe const store = new DisposableStore(); this._sessionDisposables.set(session.sessionId, store); - // Wait for the session to finish loading and report an actual worktree, - // then dispatch any pending worktreeCreated tasks once. When dispatched, - // dispose the per-session subscription store to tear down this autorun. registerAutorunSelfDisposable(store, reader => { if (session.loading.read(reader)) { return; @@ -85,7 +82,7 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe if (!session.workspace.read(reader)?.folders.some(folder => !!folder.gitRepository?.workTreeUri)) { return; } - this._sessionDisposables.deleteAndDispose(session.sessionId); + reader.dispose(); this._dispatchWorktreeCreatedTasks(session); }); } From 169748af895a42c3480d7404482818a76c310f5b Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 15:03:45 -0700 Subject: [PATCH 2/9] Follow up on Copilot comments to a previous PR (#319135) Co-authored-by: Copilot --- .../copilot/src/platform/review/vscode/reviewServiceImpl.ts | 2 +- .../copilot/src/platform/survey/vscode/surveyServiceImpl.ts | 2 +- .../src/features/validationProvider.ts | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts index a9c7c6697a606..07ed056ac8333 100644 --- a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts +++ b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts @@ -28,7 +28,7 @@ export class ReviewServiceImpl implements IReviewService { private readonly _repositoryDisposables = new DisposableStore(); private _reviewDiffReposString: string | undefined; private _diagnosticCollection: vscode.DiagnosticCollection | undefined; - private _commentController = vscode.comments.createCommentController('github-copilot-review', 'Code Review'); + private _commentController = this._disposables.add(vscode.comments.createCommentController('github-copilot-review', 'Code Review')); private _comments: InternalComment[] = []; private _monitorActiveThread: any | undefined; private _activeThread: vscode.CommentThread | undefined; diff --git a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts index c1fd39aef9285..26b87930d0868 100644 --- a/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts +++ b/extensions/copilot/src/platform/survey/vscode/surveyServiceImpl.ts @@ -72,7 +72,7 @@ export class SurveyService implements ISurveyService { this.lastLanguageId = languageId; } - if (!this.debounceTimeout) { + if (this.debounceTimeout === undefined) { this.debounceTimeout = setTimeout(async () => { const eligible = await this.checkEligibility(); if (eligible) { diff --git a/extensions/php-language-features/src/features/validationProvider.ts b/extensions/php-language-features/src/features/validationProvider.ts index 8679cce3091f0..f48384f6a5c6c 100644 --- a/extensions/php-language-features/src/features/validationProvider.ts +++ b/extensions/php-language-features/src/features/validationProvider.ts @@ -125,6 +125,12 @@ export default class PHPValidationProvider { this.documentListener.dispose(); this.documentListener = null; } + if (this.delayers) { + for (const key in this.delayers) { + this.delayers[key].cancel(); + } + this.delayers = undefined; + } } private async loadConfiguration(): Promise { From 0daa00e5c37dac3842f00c3ed02f9dd79d14dd93 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 15:04:03 -0700 Subject: [PATCH 3/9] Clear timeouts/intervals in dispose in core (#319126) --- src/vs/code/browser/workbench/workbench.ts | 6 ++++ .../contrib/suggest/browser/suggestWidget.ts | 10 +++---- .../quickinput/browser/quickInputList.ts | 6 ++-- .../browser/parts/editor/editorPart.ts | 3 ++ .../terminalToolAutoExpand.ts | 30 ++++++++----------- .../contrib/comments/browser/commentNode.ts | 16 +++------- .../debug/common/abstractDebugAdapter.ts | 15 +++++++++- .../issue/browser/issueReporterOverlay.ts | 8 ++--- .../workbench/contrib/scm/browser/scmInput.ts | 1 + .../extensions/browser/extensionUrlHandler.ts | 9 +++--- .../browser/userAttentionBrowser.ts | 5 ++++ 11 files changed, 63 insertions(+), 46 deletions(-) diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index 533cb26ae8e5c..e52f0625b2334 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -400,6 +400,12 @@ class LocalStorageURLCallbackProvider extends Disposable implements IURLCallback this.lastTimeChecked = Date.now(); } + + override dispose(): void { + clearTimeout(this.checkCallbacksTimeout); + this.stopListening(); + super.dispose(); + } } class WorkspaceProvider implements IWorkspaceProvider { diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index af10b515098d4..8c8065d65de6a 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -106,7 +106,7 @@ export class SuggestWidget implements IDisposable { private _state: State = State.Hidden; private _isAuto: boolean = false; - private _loadingTimeout?: IDisposable; + private readonly _loadingTimeout = new MutableDisposable(); private readonly _pendingLayout = new MutableDisposable(); private readonly _pendingShowDetails = new MutableDisposable(); private _currentSuggestionDetails?: CancelablePromise; @@ -313,7 +313,7 @@ export class SuggestWidget implements IDisposable { this._list.dispose(); this._status.dispose(); this._disposables.dispose(); - this._loadingTimeout?.dispose(); + this._loadingTimeout.dispose(); this._pendingLayout.dispose(); this._pendingShowDetails.dispose(); this._showTimeout.dispose(); @@ -536,14 +536,14 @@ export class SuggestWidget implements IDisposable { this._isAuto = !!auto; if (!this._isAuto) { - this._loadingTimeout = disposableTimeout(() => this._setState(State.Loading), delay); + this._loadingTimeout.value = disposableTimeout(() => this._setState(State.Loading), delay); } } showSuggestions(completionModel: CompletionModel, selectionIndex: number, isFrozen: boolean, isAuto: boolean, noFocus: boolean): void { this._contentWidget.setPosition(this.editor.getPosition()); - this._loadingTimeout?.dispose(); + this._loadingTimeout.clear(); this._currentSuggestionDetails?.cancel(); this._currentSuggestionDetails = undefined; @@ -776,7 +776,7 @@ export class SuggestWidget implements IDisposable { hideWidget(): void { this._pendingLayout.clear(); this._pendingShowDetails.clear(); - this._loadingTimeout?.dispose(); + this._loadingTimeout.clear(); this._setState(State.Hidden); this._onDidHide.fire(this); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts index f71937903f37e..530f0570bf4b9 100644 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -19,7 +19,7 @@ import { Checkbox, createToggleActionViewItemProvider, IToggleStyles } from '../ import { RenderIndentGuides } from '../../../base/browser/ui/tree/abstractTree.js'; import { IObjectTreeElement, ITreeNode, ITreeRenderer, TreeVisibility } from '../../../base/browser/ui/tree/tree.js'; import { equals } from '../../../base/common/arrays.js'; -import { ThrottledDelayer } from '../../../base/common/async.js'; +import { disposableTimeout, ThrottledDelayer } from '../../../base/common/async.js'; import { compareAnything } from '../../../base/common/comparers.js'; import { memoize } from '../../../base/common/decorators.js'; import { isCancellationError } from '../../../base/common/errors.js'; @@ -1157,7 +1157,7 @@ export class QuickInputList extends Disposable { // Accessibility hack, unfortunately on next tick // https://github.com/microsoft/vscode/issues/211976 if (this.accessibilityService.isScreenReaderOptimized()) { - setTimeout(() => { + disposableTimeout(() => { // eslint-disable-next-line no-restricted-syntax const focusedElement = this._tree.getHTMLElement().querySelector(`.monaco-list-row.focused`); const parent = focusedElement?.parentNode; @@ -1166,7 +1166,7 @@ export class QuickInputList extends Disposable { focusedElement.remove(); parent.insertBefore(focusedElement, nextSibling); } - }, 0); + }, 0, this._elementDisposable); } } diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 54cfab506840e..a5b082d63d376 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -1198,6 +1198,9 @@ export class EditorPart extends Part implements IEditorPart, onDragEnd: () => clearAllTimeouts(), onDrop: () => clearAllTimeouts() })); + + // Make sure pending opener timeouts are cleared when the part is disposed + this._register(toDisposable(() => clearAllTimeouts())); } centerLayout(active: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts index 2ccb62051f956..48e8e1bc3f494 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/terminalToolAutoExpand.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { disposableTimeout } from '../../../../../../../base/common/async.js'; @@ -62,8 +62,8 @@ export const enum TerminalToolAutoExpandTimeout { export class TerminalToolAutoExpand extends Disposable { private _commandFinished = false; private _receivedData = false; - private _dataEventTimeout: IDisposable | undefined; - private _noDataTimeout: IDisposable | undefined; + private readonly _dataEventTimeout = this._register(new MutableDisposable()); + private readonly _noDataTimeout = this._register(new MutableDisposable()); private readonly _onDidRequestExpand = this._register(new Emitter()); readonly onDidRequestExpand: Event = this._onDidRequestExpand.event; @@ -80,9 +80,9 @@ export class TerminalToolAutoExpand extends Disposable { store.add(this._options.onCommandExecuted(() => { // Auto-expand for long-running commands: - if (this._options.shouldAutoExpand() && !this._noDataTimeout) { - this._noDataTimeout = disposableTimeout(() => { - this._noDataTimeout = undefined; + if (this._options.shouldAutoExpand() && !this._noDataTimeout.value) { + this._noDataTimeout.value = disposableTimeout(() => { + this._noDataTimeout.clear(); const shouldExpand = this._options.shouldAutoExpand(); const hasOutput = this._options.hasRealOutput(); // Don't check receivedData here - data events can fire before onCommandExecuted @@ -90,8 +90,7 @@ export class TerminalToolAutoExpand extends Disposable { // if hasRealOutput was false at that time if (shouldExpand && hasOutput) { // Cancel the DataEvent timeout since we're expanding via the NoData path - this._dataEventTimeout?.dispose(); - this._dataEventTimeout = undefined; + this._dataEventTimeout.clear(); this._onDidRequestExpand.fire(); } }, TerminalToolAutoExpandTimeout.NoData, store); @@ -109,15 +108,14 @@ export class TerminalToolAutoExpand extends Disposable { } this._receivedData = true; // Wait 50ms and expand if command hasn't finished yet and has real output - if (this._options.shouldAutoExpand() && !this._dataEventTimeout) { - this._dataEventTimeout = disposableTimeout(() => { - this._dataEventTimeout = undefined; + if (this._options.shouldAutoExpand() && !this._dataEventTimeout.value) { + this._dataEventTimeout.value = disposableTimeout(() => { + this._dataEventTimeout.clear(); const shouldExpand = this._options.shouldAutoExpand(); const hasOutput = this._options.hasRealOutput(); if (!this._commandFinished && shouldExpand && hasOutput) { // Cancel the NoData timeout since we're expanding via the DataEvent path - this._noDataTimeout?.dispose(); - this._noDataTimeout = undefined; + this._noDataTimeout.clear(); this._onDidRequestExpand.fire(); } }, TerminalToolAutoExpandTimeout.DataEvent, store); @@ -131,9 +129,7 @@ export class TerminalToolAutoExpand extends Disposable { } private _clearAutoExpandTimeouts(): void { - this._dataEventTimeout?.dispose(); - this._dataEventTimeout = undefined; - this._noDataTimeout?.dispose(); - this._noDataTimeout = undefined; + this._dataEventTimeout.clear(); + this._noDataTimeout.clear(); } } diff --git a/src/vs/workbench/contrib/comments/browser/commentNode.ts b/src/vs/workbench/contrib/comments/browser/commentNode.ts index ee1177e1eb3e3..a48b0c8f789ab 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -9,6 +9,7 @@ import * as languages from '../../../../editor/common/languages.js'; import { ActionsOrientation, ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Action, IAction, Separator, ActionRunner } from '../../../../base/common/actions.js'; import { Disposable, DisposableStore, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { TimeoutTimer } from '../../../../base/common/async.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IMarkdownRendererExtraOptions, IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IRenderedMarkdown } from '../../../../base/browser/markdownRenderer.js'; @@ -63,7 +64,7 @@ export class CommentNode extends Disposable { private _avatar: HTMLElement; private readonly _md: MutableDisposable = this._register(new MutableDisposable()); private _plainText: HTMLElement | undefined; - private _clearTimeout: Timeout | null; + private readonly _focusClearTimer = this._register(new TimeoutTimer()); private _editAction: Action | null = null; private _commentEditContainer: HTMLElement | null = null; @@ -149,7 +150,6 @@ export class CommentNode extends Disposable { this._domNode.setAttribute('aria-label', `${comment.userName}, ${this.commentBodyValue}`); this._domNode.setAttribute('role', 'treeitem'); - this._clearTimeout = null; this._register(dom.addDisposableListener(this._domNode, dom.EventType.CLICK, () => this.isEditing || this._onDidClick.fire(this))); this._register(dom.addDisposableListener(this._domNode, dom.EventType.CONTEXT_MENU, e => { @@ -730,16 +730,8 @@ export class CommentNode extends Disposable { focus() { this.domNode.focus(); - if (!this._clearTimeout) { - this.domNode.classList.add('focus'); - this._clearTimeout = setTimeout(() => { - this.domNode.classList.remove('focus'); - }, 3000); - } - } - - override dispose(): void { - super.dispose(); + this.domNode.classList.add('focus'); + this._focusClearTimer.setIfNotSet(() => this.domNode.classList.remove('focus'), 3000); } } diff --git a/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts b/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts index 845a916f0a56a..4f5155d1da2e6 100644 --- a/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts +++ b/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts @@ -15,6 +15,7 @@ import { localize } from '../../../../nls.js'; export abstract class AbstractDebugAdapter implements IDebugAdapter { private sequence: number; private pendingRequests = new Map void>(); + private pendingRequestTimers = new Map(); private requestCallback: ((request: DebugProtocol.Request) => void) | undefined; private eventCallback: ((request: DebugProtocol.Event) => void) | undefined; private messageCallback: ((message: DebugProtocol.ProtocolMessage) => void) | undefined; @@ -79,7 +80,7 @@ export abstract class AbstractDebugAdapter implements IDebugAdapter { this.internalSend('request', request); if (typeof timeout === 'number') { const timer = setTimeout(() => { - clearTimeout(timer); + this.pendingRequestTimers.delete(request.seq); const clb = this.pendingRequests.get(request.seq); if (clb) { this.pendingRequests.delete(request.seq); @@ -94,6 +95,7 @@ export abstract class AbstractDebugAdapter implements IDebugAdapter { clb(err); } }, timeout); + this.pendingRequestTimers.set(request.seq, timer); } if (clb) { // store callback for this request @@ -165,6 +167,7 @@ export abstract class AbstractDebugAdapter implements IDebugAdapter { const clb = this.pendingRequests.get(response.request_seq); if (clb) { this.pendingRequests.delete(response.request_seq); + this.clearPendingRequestTimer(response.request_seq); clb(response); } break; @@ -198,14 +201,24 @@ export abstract class AbstractDebugAdapter implements IDebugAdapter { }; callback(err); this.pendingRequests.delete(request_seq); + this.clearPendingRequestTimer(request_seq); }); } + private clearPendingRequestTimer(requestSeq: number): void { + clearTimeout(this.pendingRequestTimers.get(requestSeq)); + this.pendingRequestTimers.delete(requestSeq); + } + getPendingRequestIds(): number[] { return Array.from(this.pendingRequests.keys()); } dispose(): void { + for (const timer of this.pendingRequestTimers.values()) { + clearTimeout(timer); + } + this.pendingRequestTimers.clear(); this._onError.dispose(); this._onExit.dispose(); this.queue = []; diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts index b08d0cf9f38ca..8b704044b57e1 100644 --- a/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts +++ b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts @@ -7,7 +7,7 @@ import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/key import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; import { OS } from '../../../../base/common/platform.js'; import './media/issueReporterOverlay.css'; -import { $, addDisposableListener, append, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, disposableWindowInterval, EventType, getWindow } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { IContextMenuProvider } from '../../../../base/browser/contextmenu.js'; @@ -385,12 +385,12 @@ export class IssueReporterOverlay { let remaining = this.screenshotDelay; captureBtn.label = `${remaining}...`; const targetWindow = getWindow(this.container); - const interval = targetWindow.setInterval(() => { + const intervalDisposable = this.disposables.add(disposableWindowInterval(targetWindow, () => { remaining--; if (remaining > 0) { captureBtn.label = `${remaining}...`; } else { - targetWindow.clearInterval(interval); + this.disposables.delete(intervalDisposable); captureBtn.label = `$(device-camera) ${localize('screenshot', "Screenshot")}`; captureBtn.element.style.minWidth = ''; captureBtn.enabled = true; @@ -399,7 +399,7 @@ export class IssueReporterOverlay { this.updateAttachmentButtons(); this._onDidRequestScreenshot.fire(); } - }, 1000); + }, 1000)); } else { this._onDidRequestScreenshot.fire(); } diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts index 6adf03c636d8b..63642985fc726 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts @@ -832,6 +832,7 @@ export class SCMInputWidget { this.input = undefined; this.repositoryDisposables.dispose(); this.clearValidation(); + clearTimeout(this._validationTimer); this.disposables.dispose(); } } diff --git a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts index a7b67f152e3bd..ae304b2386194 100644 --- a/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts +++ b/src/vs/workbench/services/extensions/browser/extensionUrlHandler.ts @@ -141,13 +141,14 @@ class ExtensionUrlHandler implements IExtensionUrlHandler, IURLHandler { this.handleURL(URI.revive(JSON.parse(urlToHandleValue)), { trusted: true }); } + const cache = ExtensionUrlBootstrapHandler.cache; + const drainTimeout = setTimeout(() => cache.forEach(([uri, option]) => this.handleURL(uri, option))); + this.disposable = combinedDisposable( urlService.registerHandler(this), - interval + interval, + toDisposable(() => clearTimeout(drainTimeout)) ); - - const cache = ExtensionUrlBootstrapHandler.cache; - setTimeout(() => cache.forEach(([uri, option]) => this.handleURL(uri, option))); } async handleURL(uri: URI, options?: IOpenURLOptions): Promise { diff --git a/src/vs/workbench/services/userAttention/browser/userAttentionBrowser.ts b/src/vs/workbench/services/userAttention/browser/userAttentionBrowser.ts index a2470b6cad412..fadd10a7863fe 100644 --- a/src/vs/workbench/services/userAttention/browser/userAttentionBrowser.ts +++ b/src/vs/workbench/services/userAttention/browser/userAttentionBrowser.ts @@ -127,6 +127,11 @@ export class UserAttentionServiceEnv extends Disposable { this._activityDebounceTimeout = undefined; }, 500); } + + override dispose(): void { + clearTimeout(this._activityDebounceTimeout); + super.dispose(); + } } const eventListenerOptions: AddEventListenerOptions = { From fc9ee2a94dfff89de5cbab843d49325f4892b189 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 15:04:15 -0700 Subject: [PATCH 4/9] Avoid leaking listeners in notebook search results (#319130) * Avoid leaking event listeners in notebook search * Remove redundant disposals. --- .../search/browser/searchTreeModel/searchResult.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts index 60c3e91e87546..c55c9aa68da05 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/searchResult.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, PauseableEmitter } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITextModel } from '../../../../../editor/common/model.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; @@ -25,8 +25,8 @@ export class SearchResultImpl extends Disposable implements ISearchResult { merge: mergeSearchResultEvents })); readonly onChange: Event = this._onChange.event; - private _onWillChangeModelListener: IDisposable | undefined; - private _onDidChangeModelListener: IDisposable | undefined; + private readonly _onWillChangeModelListener = this._register(new MutableDisposable()); + private readonly _onDidChangeModelListener = this._register(new MutableDisposable()); private _plainTextSearchResult: PlainTextSearchHeadingImpl; private _aiTextSearchResult: AITextSearchHeadingImpl; @@ -157,8 +157,7 @@ export class SearchResultImpl extends Disposable implements ISearchResult { private onDidAddNotebookEditorWidget(widget: NotebookEditorWidget): void { - this._onWillChangeModelListener?.dispose(); - this._onWillChangeModelListener = widget.onWillChangeModel( + this._onWillChangeModelListener.value = widget.onWillChangeModel( (model) => { if (model) { this.onNotebookEditorWidgetRemoved(widget, model?.uri); @@ -166,9 +165,8 @@ export class SearchResultImpl extends Disposable implements ISearchResult { } ); - this._onDidChangeModelListener?.dispose(); // listen to view model change as we are searching on both inputs and outputs - this._onDidChangeModelListener = widget.onDidAttachViewModel( + this._onDidChangeModelListener.value = widget.onDidAttachViewModel( () => { if (widget.hasModel()) { this.onNotebookEditorWidgetAdded(widget, widget.textModel.uri); @@ -291,8 +289,6 @@ export class SearchResultImpl extends Disposable implements ISearchResult { override async dispose(): Promise { this._aiTextSearchResult?.dispose(); this._plainTextSearchResult?.dispose(); - this._onWillChangeModelListener?.dispose(); - this._onDidChangeModelListener?.dispose(); super.dispose(); } } From 1f98b39208918cace8d36e2a4f20b7b9282508f1 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 15:04:28 -0700 Subject: [PATCH 5/9] Avoid leaving detached DOM elements in Getting Started (#319128) --- .../browser/gettingStarted.ts | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index d38efb60031b0..0cb22b9cf1741 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -18,7 +18,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { splitRecentLabel } from '../../../../base/common/labels.js'; -import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ILink, LinkedText } from '../../../../base/common/linkedText.js'; import { parse } from '../../../../base/common/marshalling.js'; import { Schemas, matchesScheme } from '../../../../base/common/network.js'; @@ -141,7 +141,7 @@ export class GettingStartedPage extends EditorPane { private categoriesPageScrollbar: DomScrollableElement | undefined; private detailsPageScrollbar: DomScrollableElement | undefined; - private detailsScrollbar: DomScrollableElement | undefined; + private readonly detailsScrollbar = this._register(new MutableDisposable()); private buildSlideThrottle = this._register(new Throttler()); @@ -149,9 +149,9 @@ export class GettingStartedPage extends EditorPane { private contextService: IContextKeyService; - private recentlyOpenedList?: GettingStartedIndexList; - private startList?: GettingStartedIndexList; - private gettingStartedList?: GettingStartedIndexList; + private readonly recentlyOpenedList = this._register(new MutableDisposable>()); + private readonly startList = this._register(new MutableDisposable>()); + private readonly gettingStartedList = this._register(new MutableDisposable>()); private stepsSlide!: HTMLElement; private categoriesSlide!: HTMLElement; @@ -510,7 +510,7 @@ export class GettingStartedPage extends EditorPane { const selectedCategory = this.gettingStartedCategories.find(category => category.id === categoryId); if (!selectedCategory) { throw Error('Could not find category with ID ' + categoryId); } this.setHiddenCategories([...this.getHiddenCategories().add(categoryId)]); - this.gettingStartedList?.rerender(); + this.gettingStartedList.value?.rerender(); } private markAllStepsComplete() { @@ -859,7 +859,7 @@ export class GettingStartedPage extends EditorPane { } this.detailsPageScrollbar?.scanDomNode(); - this.detailsScrollbar?.scanDomNode(); + this.detailsScrollbar.value?.scanDomNode(); } private updateMediaSourceForColorMode(element: HTMLImageElement, sources: { hcDark: URI; hcLight: URI; dark: URI; light: URI }) { @@ -1097,9 +1097,7 @@ export class GettingStartedPage extends EditorPane { return li; }; - if (this.recentlyOpenedList) { this.recentlyOpenedList.dispose(); } - - const recentlyOpenedList = this.recentlyOpenedList = new GettingStartedIndexList( + const recentlyOpenedList = this.recentlyOpenedList.value = new GettingStartedIndexList( { title: localize('recent', "Recent"), klass: 'recently-opened', @@ -1141,13 +1139,13 @@ export class GettingStartedPage extends EditorPane { } private refreshRecentlyOpened(): void { - if (!this.recentlyOpenedList) { + if (!this.recentlyOpenedList.value) { return; } this.recentlyOpened.then(({ workspaces }) => { const workspacesWithID = this.filterRecentlyOpened(workspaces); - this.recentlyOpenedList?.setEntries(workspacesWithID); + this.recentlyOpenedList.value?.setEntries(workspacesWithID); }).catch(onUnexpectedError); } @@ -1162,9 +1160,7 @@ export class GettingStartedPage extends EditorPane { this.iconWidgetFor(entry), $('span', {}, entry.title))); - if (this.startList) { this.startList.dispose(); } - - const startList = this.startList = new GettingStartedIndexList( + const startList = this.startList.value = new GettingStartedIndexList( { title: localize('start', "Start"), klass: 'start-container', @@ -1226,7 +1222,7 @@ export class GettingStartedPage extends EditorPane { $('.progress-bar-inner')))); }; - if (this.gettingStartedList) { this.gettingStartedList.dispose(); } + const rankWalkthrough = (e: IResolvedWalkthrough) => { let rank: number | null = e.order; @@ -1240,7 +1236,7 @@ export class GettingStartedPage extends EditorPane { return rank; }; - const gettingStartedList = this.gettingStartedList = new GettingStartedIndexList( + const gettingStartedList = this.gettingStartedList.value = new GettingStartedIndexList( { title: localize('walkthroughs', "Walkthroughs"), klass: 'getting-started', @@ -1267,14 +1263,14 @@ export class GettingStartedPage extends EditorPane { } layout(size: Dimension) { - this.detailsScrollbar?.scanDomNode(); + this.detailsScrollbar.value?.scanDomNode(); this.categoriesPageScrollbar?.scanDomNode(); this.detailsPageScrollbar?.scanDomNode(); - this.startList?.layout(size); - this.gettingStartedList?.layout(size); - this.recentlyOpenedList?.layout(size); + this.startList.value?.layout(size); + this.gettingStartedList.value?.layout(size); + this.recentlyOpenedList.value?.layout(size); if (this.editorInput?.selectedStep && this.currentMediaType) { this.mediaDisposables.clear(); @@ -1290,7 +1286,7 @@ export class GettingStartedPage extends EditorPane { this.categoriesPageScrollbar?.scanDomNode(); this.detailsPageScrollbar?.scanDomNode(); - this.detailsScrollbar?.scanDomNode(); + this.detailsScrollbar.value?.scanDomNode(); } private updateCategoryProgress() { @@ -1505,7 +1501,7 @@ export class GettingStartedPage extends EditorPane { if (!this.editorInput) { return; } - if (this.detailsScrollbar) { this.detailsScrollbar.dispose(); } + this.extensionService.whenInstalledExtensionsRegistered().then(() => { // Remove internal extension id specifier from exposed id's @@ -1636,8 +1632,8 @@ export class GettingStartedPage extends EditorPane { : []), ) ); - this.detailsScrollbar = this._register(new DomScrollableElement(stepsContainer, { className: 'steps-container' })); - const stepListComponent = this.detailsScrollbar.getDomNode(); + this.detailsScrollbar.value = new DomScrollableElement(stepsContainer, { className: 'steps-container' }); + const stepListComponent = this.detailsScrollbar.value.getDomNode(); const categoryFooter = $('.getting-started-footer'); if (this.editorInput.showTelemetryNotice && getTelemetryLevel(this.configurationService) !== TelemetryLevel.NONE && this.productService.enableTelemetry) { @@ -1649,7 +1645,7 @@ export class GettingStartedPage extends EditorPane { const toExpand = category.steps.find(step => this.contextService.contextMatchesRules(step.when) && !step.done) ?? category.steps[0]; this.selectStep(selectedStep ?? toExpand.id, !selectedStep, preserveFocus); - this.detailsScrollbar.scanDomNode(); + this.detailsScrollbar.value?.scanDomNode(); this.detailsPageScrollbar?.scanDomNode(); this.registerDispatchListeners(); @@ -1701,7 +1697,7 @@ export class GettingStartedPage extends EditorPane { this.editorInput.walkthroughPageTitle = undefined; } - if (this.gettingStartedCategories.length !== this.gettingStartedList?.itemCount) { + if (this.gettingStartedCategories.length !== this.gettingStartedList.value?.itemCount) { // extensions may have changed in the time since we last displayed the walkthrough list // rebuild the list this.buildCategoriesSlide(); From f2c2a86b740f09af4ac6066b9b8e61f03f403277 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 30 May 2026 15:44:25 -0700 Subject: [PATCH 6/9] fix: remove awaits inside Promise.race in shell integration test (#319068) Awaiting promises before passing them to Promise.race serializes the competitors and defeats the race. Pass the timeout and onDidChangeInput promises directly so the timeout can fire while waiting for input. Co-authored-by: Cursor Agent Co-authored-by: Daniel Imms --- .../browser/xterm/shellIntegrationAddon.integrationTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.integrationTest.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.integrationTest.ts index 211eb8bbe9bd5..e2711d36aeead 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.integrationTest.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/shellIntegrationAddon.integrationTest.ts @@ -221,8 +221,8 @@ suite('Terminal Contrib Shell Integration Recordings', () => { const promptInputModel = capabilities.get(TerminalCapability.CommandDetection)?.promptInputModel; if (promptInputModel && promptInputModel.getCombinedString() !== event.data) { await Promise.race([ - await timeout(1000).then(() => { throw new Error(`Prompt input change timed out current="${promptInputModel.getCombinedString()}", expected="${event.data}"`); }), - await new Promise(r => { + timeout(1000).then(() => { throw new Error(`Prompt input change timed out current="${promptInputModel.getCombinedString()}", expected="${event.data}"`); }), + new Promise(r => { const d = promptInputModel.onDidChangeInput(() => { if (promptInputModel.getCombinedString() === event.data) { d.dispose(); From f6d1fcfcfcb5225125221ff6fdd6ae8c699958d5 Mon Sep 17 00:00:00 2001 From: Bhavya U Date: Sat, 30 May 2026 15:56:15 -0700 Subject: [PATCH 7/9] Report 1h vs 5m Anthropic cache-creation token split in telemetry (#319172) Parse the per-TTL breakdown from Anthropic's usage.cache_creation object (present in message_start across CAPI/Anthropic 1P, Bedrock InvokeModel, and Vertex AI) and emit two new measurements on response.success: - promptCacheCreation1hTokenCount: 1h-TTL writes (2x base input rate) - promptCacheCreation5mTokenCount: 5m-TTL writes (1.25x base input rate) Enables exact per-row COGS attribution for the chat.anthropic.promptCaching.extendedTtl A/B experiment without inferring rates from arm assignment. The new fields live on a nested anthropic_cache_creation? object on APIUsage.prompt_tokens_details, namespaced to make the provider-specificity explicit at the type level. Other providers leave it undefined; telemetry uses optional chaining so missing values drop cleanly from the row. --- .../prompt/node/chatMLFetcherTelemetry.ts | 4 + .../src/platform/endpoint/node/messagesApi.ts | 32 +++ .../endpoint/test/node/messagesApi.spec.ts | 195 +++++++++++++++++- .../src/platform/networking/common/openai.ts | 13 ++ 4 files changed, 243 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts index ea50562771440..ff35a497306de 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcherTelemetry.ts @@ -149,6 +149,8 @@ export class ChatMLFetcherTelemetrySender { "clientPromptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of prompt tokens, locally counted", "isMeasurement": true }, "promptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of prompt tokens, server side counted", "isMeasurement": true }, "promptCacheTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of prompt tokens hitting cache as reported by server", "isMeasurement": true }, + "promptCacheCreation1hTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Cache-creation input tokens written with the 1h (extended) TTL, billed at 2x base rate. Only populated when Anthropic reports the cache_creation breakdown.", "isMeasurement": true }, + "promptCacheCreation5mTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Cache-creation input tokens written with the default 5m TTL, billed at 1.25x base rate. Only populated when Anthropic reports the cache_creation breakdown.", "isMeasurement": true }, "tokenCountMax": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Maximum generated tokens", "isMeasurement": true }, "tokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of generated tokens", "isMeasurement": true }, "reasoningTokens": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of reasoning tokens", "isMeasurement": true }, @@ -227,6 +229,8 @@ export class ChatMLFetcherTelemetrySender { tokenCountMax: maxResponseTokens, promptTokenCount: chatCompletion.usage?.prompt_tokens, promptCacheTokenCount: chatCompletion.usage?.prompt_tokens_details?.cached_tokens, + promptCacheCreation1hTokenCount: chatCompletion.usage?.prompt_tokens_details?.anthropic_cache_creation?.ephemeral_1h_input_tokens, + promptCacheCreation5mTokenCount: chatCompletion.usage?.prompt_tokens_details?.anthropic_cache_creation?.ephemeral_5m_input_tokens, clientPromptTokenCount: promptTokenCount, tokenCount: chatCompletion.usage?.total_tokens, reasoningTokens: chatCompletion.usage?.completion_tokens_details?.reasoning_tokens, diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 4d652790ae18b..5b649bbeae8ca 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -66,6 +66,10 @@ interface AnthropicStreamEvent { output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; }; }; index?: number; @@ -92,6 +96,10 @@ interface AnthropicStreamEvent { input_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; }; copilot_usage?: { total_nano_aiu: number; @@ -666,6 +674,8 @@ interface AnthropicCompletionState { readonly inputTokens: number; readonly outputTokens: number; readonly cacheCreationTokens: number; + readonly cacheCreation1hTokens: number | undefined; + readonly cacheCreation5mTokens: number | undefined; readonly cacheReadTokens: number; readonly requestId: string; readonly ghRequestId: string; @@ -724,6 +734,14 @@ function buildAnthropicCompletion(state: AnthropicCompletionState, logService: I prompt_tokens_details: { cached_tokens: state.cacheReadTokens, cache_creation_input_tokens: state.cacheCreationTokens, + ...(state.cacheCreation1hTokens !== undefined || state.cacheCreation5mTokens !== undefined + ? { + anthropic_cache_creation: { + ...(state.cacheCreation1hTokens !== undefined ? { ephemeral_1h_input_tokens: state.cacheCreation1hTokens } : {}), + ...(state.cacheCreation5mTokens !== undefined ? { ephemeral_5m_input_tokens: state.cacheCreation5mTokens } : {}), + }, + } + : {}), }, completion_tokens_details: { reasoning_tokens: 0, @@ -776,6 +794,10 @@ type AnthropicNonStreamingResponse = output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; }; } | { @@ -908,6 +930,8 @@ export async function processNonStreamingResponseFromMessagesEndpoint( inputTokens: usage?.input_tokens ?? 0, outputTokens: usage?.output_tokens ?? 0, cacheCreationTokens: usage?.cache_creation_input_tokens ?? 0, + cacheCreation1hTokens: usage?.cache_creation?.ephemeral_1h_input_tokens, + cacheCreation5mTokens: usage?.cache_creation?.ephemeral_5m_input_tokens, cacheReadTokens: usage?.cache_read_input_tokens ?? 0, requestId, ghRequestId, @@ -956,6 +980,8 @@ export class AnthropicMessagesProcessor { private inputTokens: number = 0; private outputTokens: number = 0; private cacheCreationTokens: number = 0; + private cacheCreation1hTokens: number | undefined; + private cacheCreation5mTokens: number | undefined; private cacheReadTokens: number = 0; private copilotUsage?: { total_nano_aiu: number }; private contextManagementResponse?: ContextManagementResponse; @@ -1036,6 +1062,8 @@ export class AnthropicMessagesProcessor { this.inputTokens = chunk.message.usage.input_tokens ?? 0; this.outputTokens = chunk.message.usage.output_tokens ?? 0; this.cacheCreationTokens = chunk.message.usage.cache_creation_input_tokens ?? 0; + this.cacheCreation1hTokens = chunk.message.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens; + this.cacheCreation5mTokens = chunk.message.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens; this.cacheReadTokens = chunk.message.usage.cache_read_input_tokens ?? 0; } return; @@ -1146,6 +1174,8 @@ export class AnthropicMessagesProcessor { this.outputTokens = chunk.usage.output_tokens; this.inputTokens = chunk.usage.input_tokens ?? this.inputTokens; this.cacheCreationTokens = chunk.usage.cache_creation_input_tokens ?? this.cacheCreationTokens; + this.cacheCreation1hTokens = chunk.usage.cache_creation?.ephemeral_1h_input_tokens ?? this.cacheCreation1hTokens; + this.cacheCreation5mTokens = chunk.usage.cache_creation?.ephemeral_5m_input_tokens ?? this.cacheCreation5mTokens; this.cacheReadTokens = chunk.usage.cache_read_input_tokens ?? this.cacheReadTokens; } if (chunk.copilot_usage && typeof chunk.copilot_usage.total_nano_aiu === 'number') { @@ -1239,6 +1269,8 @@ export class AnthropicMessagesProcessor { inputTokens: this.inputTokens, outputTokens: this.outputTokens, cacheCreationTokens: this.cacheCreationTokens, + cacheCreation1hTokens: this.cacheCreation1hTokens, + cacheCreation5mTokens: this.cacheCreation5mTokens, cacheReadTokens: this.cacheReadTokens, requestId: this.requestId, ghRequestId: this.ghRequestId, diff --git a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts index 1fe0697e5845c..7d53f9bd28eb7 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts @@ -13,7 +13,7 @@ import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME, isExtendedCacheTtlEnabl import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking'; import { IToolDeferralService } from '../../../networking/common/toolDeferralService'; import { createPlatformServices } from '../../../test/node/services'; -import { addMessagesApiCacheControl, addToolsAndSystemCacheControl, buildToolInputSchema, clearAllCacheControl, createMessagesRequestBody, processNonStreamingResponseFromMessagesEndpoint, processResponseFromMessagesEndpoint, rawMessagesToMessagesAPI } from '../../node/messagesApi'; +import { addMessagesApiCacheControl, addToolsAndSystemCacheControl, AnthropicMessagesProcessor, buildToolInputSchema, clearAllCacheControl, createMessagesRequestBody, processNonStreamingResponseFromMessagesEndpoint, processResponseFromMessagesEndpoint, rawMessagesToMessagesAPI } from '../../node/messagesApi'; import { HeadersImpl, Response } from '../../../networking/common/fetcherService'; import { TelemetryData } from '../../../telemetry/common/telemetryData'; import { TestLogService } from '../../../testing/common/testLogService'; @@ -1404,6 +1404,81 @@ suite('processNonStreamingResponseFromMessagesEndpoint', () => { expect(results[0].usage?.prompt_tokens_details?.cached_tokens).toBe(30); }); + test('surfaces 1h/5m cache_creation split when present', async () => { + const response = createNonStreamingResponse({ + id: 'msg_cache_ttl', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'cached' }], + model: 'claude-sonnet-4-20250514', + stop_reason: 'end_turn', + usage: { + input_tokens: 50, + output_tokens: 10, + cache_creation_input_tokens: 25, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 17, + ephemeral_5m_input_tokens: 8, + }, + }, + }); + + const telemetryData = TelemetryData.createAndMarkAsIssued(); + const completions = await processNonStreamingResponseFromMessagesEndpoint( + new NullTelemetryService(), + new TestLogService(), + response, + async () => undefined, + telemetryData, + ); + + const results = []; + for await (const c of completions) { + results.push(c); + } + + const details = results[0].usage?.prompt_tokens_details; + expect(details?.cache_creation_input_tokens).toBe(25); + expect(details?.anthropic_cache_creation?.ephemeral_1h_input_tokens).toBe(17); + expect(details?.anthropic_cache_creation?.ephemeral_5m_input_tokens).toBe(8); + }); + + test('omits 1h/5m split fields when Anthropic does not report them', async () => { + const response = createNonStreamingResponse({ + id: 'msg_cache_no_split', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'cached' }], + model: 'claude-sonnet-4-20250514', + stop_reason: 'end_turn', + usage: { + input_tokens: 50, + output_tokens: 10, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 30, + }, + }); + + const telemetryData = TelemetryData.createAndMarkAsIssued(); + const completions = await processNonStreamingResponseFromMessagesEndpoint( + new NullTelemetryService(), + new TestLogService(), + response, + async () => undefined, + telemetryData, + ); + + const results = []; + for await (const c of completions) { + results.push(c); + } + + const details = results[0].usage?.prompt_tokens_details; + expect(details?.cache_creation_input_tokens).toBe(20); + expect(details?.anthropic_cache_creation).toBeUndefined(); + }); + test('rejects on malformed JSON', async () => { const response = Response.fromText(200, 'OK', createNonStreamingHeaders(), 'not json at all', 'node-fetch'); const telemetryData = TelemetryData.createAndMarkAsIssued(); @@ -1555,3 +1630,121 @@ suite('processResponseFromMessagesEndpoint routing', () => { expect(results[0].message.content).toHaveLength(1); }); }); + +suite('AnthropicMessagesProcessor streaming cache_creation', () => { + function makeProcessor(): AnthropicMessagesProcessor { + return new AnthropicMessagesProcessor( + TelemetryData.createAndMarkAsIssued(), + 'req-1', + 'gh-req-1', + '', + new TestLogService(), + new NullTelemetryService(), + ); + } + + test('message_start cache_creation survives a message_delta that omits the breakdown', () => { + // Production happy path: Anthropic only emits the cache_creation breakdown + // in message_start. message_delta updates other usage fields but typically + // has no cache_creation. The ?? fallback in the processor must preserve + // the values seen in message_start — including 0 (a common control-arm + // value) which would be wiped out by a `||` regression. + const processor = makeProcessor(); + const noop = async () => undefined; + + processor.push({ + type: 'message_start', + message: { + id: 'msg_stream', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-20250514', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 5, + output_tokens: 0, + cache_creation_input_tokens: 12336, + cache_read_input_tokens: 391352, + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 12336, + }, + }, + }, + }, noop); + + // message_delta with usage but no cache_creation breakdown — mirrors + // what every observed backend (Anthropic 1P, Bedrock, Vertex) emits in + // the final delta of a stream. + processor.push({ + type: 'message_delta', + delta: { type: 'message_delta', stop_reason: 'end_turn' }, + usage: { + output_tokens: 42, + input_tokens: 5, + cache_creation_input_tokens: 12336, + cache_read_input_tokens: 391352, + }, + }, noop); + + const completion = processor.push({ type: 'message_stop' }, noop); + expect(completion).toBeDefined(); + + const details = completion!.usage?.prompt_tokens_details; + expect(details?.anthropic_cache_creation?.ephemeral_1h_input_tokens).toBe(0); + expect(details?.anthropic_cache_creation?.ephemeral_5m_input_tokens).toBe(12336); + }); + + test('message_delta cache_creation overrides message_start values', () => { + // Defensive: if a backend ever did emit the breakdown in message_delta, + // the later values should win (matches the existing overwrite pattern + // for cache_creation_input_tokens / cache_read_input_tokens). + const processor = makeProcessor(); + const noop = async () => undefined; + + processor.push({ + type: 'message_start', + message: { + id: 'msg_stream_override', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-20250514', + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 5, + output_tokens: 0, + cache_creation_input_tokens: 10000, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 10000, + }, + }, + }, + }, noop); + + processor.push({ + type: 'message_delta', + delta: { type: 'message_delta', stop_reason: 'end_turn' }, + usage: { + output_tokens: 10, + input_tokens: 5, + cache_creation_input_tokens: 15000, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 5000, + ephemeral_5m_input_tokens: 10000, + }, + }, + }, noop); + + const completion = processor.push({ type: 'message_stop' }, noop); + const details = completion!.usage?.prompt_tokens_details; + expect(details?.anthropic_cache_creation?.ephemeral_1h_input_tokens).toBe(5000); + expect(details?.anthropic_cache_creation?.ephemeral_5m_input_tokens).toBe(10000); + }); +}); diff --git a/extensions/copilot/src/platform/networking/common/openai.ts b/extensions/copilot/src/platform/networking/common/openai.ts index 307b1fb49a2a0..ced94087deb47 100644 --- a/extensions/copilot/src/platform/networking/common/openai.ts +++ b/extensions/copilot/src/platform/networking/common/openai.ts @@ -43,6 +43,19 @@ export interface APIUsage { prompt_tokens_details?: { cached_tokens: number; cache_creation_input_tokens?: number; + /** + * Anthropic-specific: per-TTL breakdown of cache-creation (write) input + * tokens. Mirrors Anthropic's `usage.cache_creation` object verbatim. + * Only populated for Anthropic Messages API responses where the server + * reports the split; absent for all other providers and for older + * Anthropic responses that don't include the breakdown. + */ + anthropic_cache_creation?: { + /** Cache-creation tokens written with the 1h (extended) TTL — billed at 2x base input rate. */ + ephemeral_1h_input_tokens?: number; + /** Cache-creation tokens written with the default 5m TTL — billed at 1.25x base input rate. */ + ephemeral_5m_input_tokens?: number; + }; }; /** * Breakdown of tokens used in a completion. From 7eff9ee6bd6f4bd34c0cbe46837156d452d8a614 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Sat, 30 May 2026 16:38:31 -0700 Subject: [PATCH 8/9] Propagate error from zipfile to the caller of read() (#319175) * Propagate error from zipfile to the caller of read() * PR feedback --- src/vs/base/node/zip.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 090db2da3f00e..471031e2f53f0 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -230,6 +230,7 @@ export function extract(zipPath: string, targetPath: string, options: IExtractOp function read(zipPath: string, filePath: string): Promise { return openZip(zipPath).then(zipfile => { return new Promise((c, e) => { + zipfile.once('error', err => e(toExtractError(err))); zipfile.on('entry', (entry: Entry) => { if (entry.fileName === filePath) { openZipStream(zipfile, entry).then(stream => c(stream), err => e(err)); From c9ce2c417d8a8f98d8ecc1733869bdd6efeb91d8 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sun, 31 May 2026 01:58:12 +0200 Subject: [PATCH 9/9] fix: memory leak extension actions (#315054) * fix: memory leak in extension actions * Reuse runAction helper to dispose actions --------- Co-authored-by: Dmitriy Vasyura --- .../browser/extensions.contribution.ts | 28 +++++++++---------- .../extensions/browser/extensionsActions.ts | 14 ++++++++-- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 5dee85d99bcf1..59c8b4c80fbff 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -567,9 +567,9 @@ const CONTEXT_GALLERY_ALL_PRIVATE_REPOSITORY_SIGNED = new RawContextKey const CONTEXT_GALLERY_HAS_EXTENSION_LINK = new RawContextKey('galleryHasExtensionLink', false); const CONTEXT_EXTENSIONS_AUTO_UPDATE_POLICY = new RawContextKey('extensionsAutoUpdatePolicy', false); -async function runAction(action: IAction): Promise { +async function runAction(action: IAction): Promise { try { - await action.run(); + return await action.run() as T; } finally { if (isDisposable(action)) { action.dispose(); @@ -1397,7 +1397,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(SetColorThemeAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1418,7 +1418,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(SetFileIconThemeAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1439,7 +1439,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(SetProductIconThemeAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1498,7 +1498,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(ToggleAutoUpdateForExtensionAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1521,7 +1521,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(ToggleAutoUpdatesForPublisherAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1543,7 +1543,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(TogglePreReleaseExtensionAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1565,7 +1565,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(TogglePreReleaseExtensionAction); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1585,7 +1585,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi const extension = (await extensionsWorkbenchService.getExtensions([{ id: extensionId }], CancellationToken.None))[0]; const action = instantiationService.createInstance(ClearLanguageAction); action.extension = extension; - return action.run(); + return runAction(action); } }); @@ -1606,7 +1606,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi if (extension) { const action = instantiationService.createInstance(InstallAction, { installPreReleaseVersion: this.extensionManagementService.preferPreReleases }); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1630,7 +1630,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi isMachineScoped: true, }); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1654,7 +1654,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi preRelease: true }); action.extension = extension; - return action.run(); + return runAction(action); } } }); @@ -1673,7 +1673,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi const extension = this.extensionsWorkbenchService.local.filter(e => areSameExtensions(e.identifier, { id: extensionId }))[0] || (await this.extensionsWorkbenchService.getExtensions([{ id: extensionId }], CancellationToken.None))[0]; if (extension) { - return instantiationService.createInstance(InstallAnotherVersionAction, extension, false).run(); + return runAction(instantiationService.createInstance(InstallAnotherVersionAction, extension, false)); } } }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 8c917ba4fac32..c24ea1c2fca44 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -3156,14 +3156,24 @@ export class InstallSpecificVersionOfExtensionAction extends Action { const extensionPick = await this.quickInputService.pick(this.getExtensionEntries(), { placeHolder: localize('selectExtension', "Select Extension"), matchOnDetail: true }); if (extensionPick && extensionPick.extension) { const action = this.instantiationService.createInstance(InstallAnotherVersionAction, extensionPick.extension, true); - await action.run(); + // TODO: replace with `using` once available + try { + await action.run(); + } finally { + action.dispose(); + } await this.extensionsWorkbenchService.openSearch(extensionPick.extension.identifier.id); } } private isEnabled(extension: IExtension): boolean { const action = this.instantiationService.createInstance(InstallAnotherVersionAction, extension, true); - return action.enabled && !!extension.local && this.extensionEnablementService.isEnabled(extension.local); + // TODO: replace with `using` once available + try { + return action.enabled && !!extension.local && this.extensionEnablementService.isEnabled(extension.local); + } finally { + action.dispose(); + } } private async getExtensionEntries(): Promise {