Skip to content

Commit 8252865

Browse files
authored
Fix editing session lifecycle issues (microsoft#229807)
* Redo the way an edit session gets disposed and add more assertions to catch any incorrect usages * Fix lifecycle issues, also don't capture in the click closure the initial contents of the editing session
1 parent 31afbe3 commit 8252865

File tree

5 files changed

+84
-73
lines changed

5 files changed

+84
-73
lines changed

src/vs/workbench/contrib/chat/browser/chatEditingService.ts

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@ import { Sequencer } from '../../../../base/common/async.js';
77
import { Codicon } from '../../../../base/common/codicons.js';
88
import { BugIndicatingError } from '../../../../base/common/errors.js';
99
import { Emitter } from '../../../../base/common/event.js';
10-
import { Disposable, IReference } from '../../../../base/common/lifecycle.js';
11-
import { ResourceSet } from '../../../../base/common/map.js';
10+
import { Disposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js';
1211
import { Schemas } from '../../../../base/common/network.js';
1312
import { derived, IObservable, ITransaction, observableValue, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js';
1413
import { URI } from '../../../../base/common/uri.js';
@@ -52,6 +51,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
5251
_serviceBrand: undefined;
5352

5453
private readonly _currentSessionObs = observableValue<ChatEditingSession | null>(this, null);
54+
private readonly _currentSessionDisposeListener = this._register(new MutableDisposable());
5555

5656
get currentEditingSession(): IChatEditingSession | null {
5757
return this._currentSessionObs.get();
@@ -62,11 +62,6 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
6262
return this._onDidCreateEditingSession.event;
6363
}
6464

65-
private readonly _onDidDisposeEditingSession = new Emitter<IChatEditingSession>();
66-
get onDidDisposeEditingSession() {
67-
return this._onDidDisposeEditingSession.event;
68-
}
69-
7065
constructor(
7166
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
7267
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@@ -90,11 +85,16 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
9085
}));
9186
this._register(this._chatService.onDidDisposeSession((e) => {
9287
if (e.reason === 'cleared' && this._currentSessionObs.get()?.chatSessionId === e.sessionId) {
93-
this._killCurrentEditingSession();
88+
void this._currentSessionObs.get()?.stop();
9489
}
9590
}));
9691
}
9792

93+
override dispose(): void {
94+
this._currentSessionObs.get()?.dispose();
95+
super.dispose();
96+
}
97+
9898
async startOrContinueEditingSession(chatSessionId: string, builder?: (stream: IChatEditingSessionStream) => Promise<void>, options?: { silent: boolean }): Promise<void> {
9999
const session = this._currentSessionObs.get();
100100
if (session) {
@@ -121,12 +121,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
121121
const editorPane = options?.silent ? undefined : await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }) as MultiDiffEditor | undefined;
122122

123123
const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, editorPane);
124-
const disposeListener = session.onDidDispose(() => {
124+
this._currentSessionDisposeListener.value = session.onDidDispose(() => {
125+
this._currentSessionDisposeListener.clear();
125126
this._currentSessionObs.set(null, undefined);
126-
this._onDidDisposeEditingSession.fire(session);
127-
disposeListener.dispose();
128127
});
129-
this._register(disposeListener);
130128

131129
this._currentSessionObs.set(session, undefined);
132130
this._onDidCreateEditingSession.fire(session);
@@ -179,15 +177,6 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
179177
}
180178
}
181179

182-
private _killCurrentEditingSession() {
183-
const currentSession = this._currentSessionObs.get();
184-
if (currentSession) {
185-
this._onDidDisposeEditingSession.fire(currentSession);
186-
currentSession.dispose();
187-
this._currentSessionObs.set(null, undefined);
188-
}
189-
}
190-
191180
private _findGroupedEditors() {
192181
const editors: [IEditorGroup, EditorInput][] = [];
193182
for (const group of this._editorGroupsService.groups) {
@@ -495,7 +484,7 @@ export class ChatEditingStopSessionAction extends Action2 {
495484

496485
async run(accessor: ServicesAccessor, ...args: any[]): Promise<void> {
497486
const chatEditingService = accessor.get(IChatEditingService);
498-
chatEditingService.currentEditingSession?.dispose();
487+
await chatEditingService.currentEditingSession?.stop();
499488
}
500489
}
501490
registerAction2(ChatEditingStopSessionAction);
@@ -572,28 +561,32 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
572561
private readonly _state = observableValue<ChatEditingSessionState>(this, ChatEditingSessionState.Initial);
573562
private readonly _entriesObs = observableValue<readonly ModifiedFileEntry[]>(this, []);
574563
public get entries(): IObservable<readonly ModifiedFileEntry[]> {
564+
this._assertNotDisposed();
575565
return this._entriesObs;
576566
}
577567
private readonly _sequencer = new Sequencer();
578568

579569
private _entries: ModifiedFileEntry[] = [];
580570

581571
get state(): IObservable<ChatEditingSessionState> {
572+
this._assertNotDisposed();
582573
return this._state;
583574
}
584575

585-
private readonly _editedResources = new ResourceSet();
586576
private readonly _onDidChange = new Emitter<void>();
587577
get onDidChange() {
578+
this._assertNotDisposed();
588579
return this._onDidChange.event;
589580
}
590581

591582
private readonly _onDidDispose = new Emitter<void>();
592583
get onDidDispose() {
584+
this._assertNotDisposed();
593585
return this._onDidDispose.event;
594586
}
595587

596588
get isVisible(): boolean {
589+
this._assertNotDisposed();
597590
return Boolean(this.editorPane && this.editorPane.isVisible());
598591
}
599592

@@ -608,10 +601,14 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
608601
super();
609602
}
610603

611-
async accept(...uris: URI[]): Promise<void> {
612-
if (this.state.get() === ChatEditingSessionState.Disposed) {
613-
return;
604+
private _assertNotDisposed(): void {
605+
if (this._state.get() === ChatEditingSessionState.Disposed) {
606+
throw new BugIndicatingError(`Cannot access a disposed editing session`);
614607
}
608+
}
609+
610+
async accept(...uris: URI[]): Promise<void> {
611+
this._assertNotDisposed();
615612

616613
if (uris.length === 0) {
617614
await Promise.all(this._entries.map(entry => entry.accept(undefined)));
@@ -628,9 +625,7 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
628625
}
629626

630627
async reject(...uris: URI[]): Promise<void> {
631-
if (this.state.get() === ChatEditingSessionState.Disposed) {
632-
return;
633-
}
628+
this._assertNotDisposed();
634629

635630
if (uris.length === 0) {
636631
await Promise.all(this._entries.map(entry => entry.reject(undefined)));
@@ -647,6 +642,8 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
647642
}
648643

649644
async show(): Promise<void> {
645+
this._assertNotDisposed();
646+
650647
if (this.editorPane?.isVisible()) {
651648
return;
652649
} else if (this.editorPane?.input) {
@@ -663,28 +660,39 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
663660
this.editorPane = editorPane;
664661
}
665662

666-
override dispose() {
663+
async stop(): Promise<void> {
664+
this._assertNotDisposed();
665+
667666
// Close out all open files
668-
this._editorGroupsService.groups.forEach((g) => {
669-
g.editors.forEach((e) => {
667+
await Promise.allSettled(this._editorGroupsService.groups.map(async (g) => {
668+
return Promise.allSettled(g.editors.map(async (e) => {
670669
if (e instanceof MultiDiffEditorInput || e instanceof DiffEditorInput && (e.original.resource?.scheme === ModifiedFileEntry.scheme || e.original.resource?.scheme === ChatEditingTextModelContentProvider.scheme)) {
671-
g.closeEditor(e);
670+
await g.closeEditor(e);
672671
}
673-
});
674-
});
672+
}));
673+
}));
674+
675+
this.dispose();
676+
}
677+
678+
override dispose() {
679+
this._assertNotDisposed();
675680

676681
super.dispose();
677682
this._state.set(ChatEditingSessionState.Disposed, undefined);
678683
this._onDidDispose.fire();
679684
}
680685

681686
getVirtualModel(documentId: string): ITextModel | null {
687+
this._assertNotDisposed();
688+
682689
const entry = this._entries.find(e => e.entryId === documentId);
683690
return entry?.docSnapshot ?? null;
684691
}
685692

686693
acceptStreamingEditsStart(): void {
687-
if (this.state.get() === ChatEditingSessionState.Disposed) {
694+
if (this._state.get() === ChatEditingSessionState.Disposed) {
695+
// we don't throw in this case because there could be a builder still connected to a disposed session
688696
return;
689697
}
690698

@@ -693,7 +701,8 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
693701
}
694702

695703
acceptTextEdits(resource: URI, textEdits: TextEdit[]): void {
696-
if (this.state.get() === ChatEditingSessionState.Disposed) {
704+
if (this._state.get() === ChatEditingSessionState.Disposed) {
705+
// we don't throw in this case because there could be a builder still connected to a disposed session
697706
return;
698707
}
699708

@@ -702,7 +711,8 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
702711
}
703712

704713
resolve(): void {
705-
if (this.state.get() === ChatEditingSessionState.Disposed) {
714+
if (this._state.get() === ChatEditingSessionState.Disposed) {
715+
// we don't throw in this case because there could be a builder still connected to a disposed session
706716
return;
707717
}
708718

@@ -734,7 +744,6 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
734744
this._register(entry);
735745
this._entries = [...this._entries, entry];
736746
this._entriesObs.set(this._entries, undefined);
737-
this._editedResources.add(resource);
738747
this._onDidChange.fire();
739748

740749
return entry;

src/vs/workbench/contrib/chat/browser/chatInputPart.ts

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -851,13 +851,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
851851

852852
if (this._chatEditList && chatEditingSession.state.get() === ChatEditingSessionState.Idle) {
853853
this._chatEditsProgress?.stop();
854-
return;
854+
this._chatEditsProgress?.dispose();
855+
this._chatEditsProgress = undefined;
855856
}
856857

857858
// Summary of number of files changed
858859
const innerContainer = this.chatEditingSessionWidgetContainer.querySelector('.chat-editing-session-container.show-file-icons') as HTMLElement ?? dom.append(this.chatEditingSessionWidgetContainer, $('.chat-editing-session-container.show-file-icons'));
859-
const editedFiles = chatEditingSession.entries.get();
860-
const numberOfEditedEntries = editedFiles.length;
860+
const numberOfEditedEntries = chatEditingSession.entries.get().length;
861861
const overviewRegion = innerContainer.querySelector('.chat-editing-session-overview') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-overview'));
862862
if (numberOfEditedEntries !== this._chatEditList?.object.length) {
863863
const overviewText = overviewRegion.querySelector('span') ?? dom.append(overviewRegion, $('span'));
@@ -869,7 +869,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
869869
// Chat editing session actions
870870
let actionsContainer = overviewRegion.querySelector('.chat-editing-session-actions') as HTMLElement;
871871
const actions = [];
872-
if (!actionsContainer && editedFiles.find((e) => e.state.get() === ModifiedFileEntryState.Undecided)) {
872+
if (!actionsContainer && chatEditingSession.entries.get().find((e) => e.state.get() === ModifiedFileEntryState.Undecided)) {
873873
// Don't show Accept All / Discard All actions if user already selected Accept All / Discard All
874874
actionsContainer = dom.append(overviewRegion, $('.chat-editing-session-actions'));
875875
actions.push(
@@ -891,24 +891,22 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
891891
);
892892

893893
for (const action of actions) {
894-
const button = this._register(new Button(actionsContainer, {
894+
const button = this._chatEditsDisposables.add(new Button(actionsContainer, {
895895
supportIcons: false,
896896
secondary: action.isSecondary
897897
}));
898898
button.label = action.label;
899-
this._register(button.onDidClick(() => {
899+
this._chatEditsDisposables.add(button.onDidClick(() => {
900900
this.commandService.executeCommand(action.command);
901901
}));
902902
dom.append(actionsContainer, button.element);
903903
}
904904

905-
const clearButton = new Button(actionsContainer, { supportIcons: true });
906-
this._chatEditsDisposables.add(clearButton);
905+
const clearButton = this._chatEditsDisposables.add(new Button(actionsContainer, { supportIcons: true }));
907906
clearButton.icon = Codicon.close;
908-
const disp = clearButton.onDidClick((e) => {
909-
disp.dispose();
910-
chatEditingSession.dispose();
911-
});
907+
this._chatEditsDisposables.add(clearButton.onDidClick((e) => {
908+
void chatEditingSession.stop();
909+
}));
912910
dom.append(actionsContainer, clearButton.element);
913911
}
914912

@@ -918,21 +916,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
918916
}
919917

920918
// List of edited files
921-
if (!editedFiles.length || editedFiles.length === this._chatEditList?.object.length) {
922-
return;
923-
}
924-
const entries: IChatCollapsibleListItem[] = editedFiles.map((entry) => ({
925-
reference: entry.modifiedURI,
926-
kind: 'reference',
927-
}));
928919
if (!this._chatEditList) {
929920
this._chatEditList = this._chatEditsListPool.get();
930921
const list = this._chatEditList.object;
931922
this._chatEditsDisposables.add(this._chatEditList);
932923
this._chatEditsDisposables.add(list.onDidOpen((e) => {
933924
if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) {
934925
const modifiedFileUri = e.element.reference;
935-
const editedFile = editedFiles.find((e) => e.modifiedURI.toString() === modifiedFileUri.toString());
926+
const editedFile = chatEditingSession.entries.get().find((e) => e.modifiedURI.toString() === modifiedFileUri.toString());
936927
if (editedFile) {
937928
void this.editorService.openEditor({
938929
original: { resource: URI.from(editedFile.originalURI, true) },
@@ -950,6 +941,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
950941
const list = this._chatEditList.object;
951942
list.layout(height);
952943
list.getHTMLElement().style.height = `${height}px`;
944+
const entries: IChatCollapsibleListItem[] = chatEditingSession.entries.get().map((entry) => ({
945+
reference: entry.modifiedURI,
946+
kind: 'reference',
947+
}));
953948
list.splice(0, list.length, entries);
954949
}
955950

src/vs/workbench/contrib/chat/browser/chatWidget.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -250,20 +250,25 @@ export class ChatWidget extends Disposable implements IChatWidget {
250250

251251
this._codeBlockModelCollection = this._register(instantiationService.createInstance(CodeBlockModelCollection));
252252

253-
let shouldRerenderEditingStateDisposable: IDisposable | null = null;
253+
const chatEditingSessionDisposables = this._register(new DisposableStore());
254+
254255
this._register(this.chatEditingService.onDidCreateEditingSession((session) => {
255-
if (session.chatSessionId === this.viewModel?.sessionId) {
256-
shouldRerenderEditingStateDisposable = this._register(session.onDidChange(() => {
257-
this.renderChatEditingSessionState(session);
258-
}));
259-
this.renderChatEditingSessionState(session);
256+
if (session.chatSessionId !== this.viewModel?.sessionId) {
257+
// this chat editing session is for a different chat widget
258+
return;
260259
}
261-
}));
262-
this._register(this.chatEditingService.onDidDisposeEditingSession((session) => {
263-
if (session.chatSessionId === this.viewModel?.sessionId || !this.viewModel) {
264-
shouldRerenderEditingStateDisposable?.dispose();
260+
// make sure to clean up anything related to the prev session (if any)
261+
chatEditingSessionDisposables.clear();
262+
this.renderChatEditingSessionState(null); // this is necessary to make sure we dispose previous buttons, etc.
263+
264+
chatEditingSessionDisposables.add(session.onDidChange(() => {
265+
this.renderChatEditingSessionState(session);
266+
}));
267+
chatEditingSessionDisposables.add(session.onDidDispose(() => {
268+
chatEditingSessionDisposables.clear();
265269
this.renderChatEditingSessionState(null);
266-
}
270+
}));
271+
this.renderChatEditingSessionState(session);
267272
}));
268273

269274
this._register(codeEditorService.registerCodeEditorOpenHandler(async (input: ITextResourceEditorInput, _source: ICodeEditor | null, _sideBySide?: boolean): Promise<ICodeEditor | null> => {

src/vs/workbench/contrib/chat/common/chatEditingService.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export interface IChatEditingService {
1515
_serviceBrand: undefined;
1616

1717
readonly onDidCreateEditingSession: Event<IChatEditingSession>;
18-
readonly onDidDisposeEditingSession: Event<IChatEditingSession>;
1918

2019
readonly currentEditingSession: IChatEditingSession | null;
2120

@@ -25,13 +24,17 @@ export interface IChatEditingService {
2524
export interface IChatEditingSession {
2625
readonly chatSessionId: string;
2726
readonly onDidChange: Event<void>;
27+
readonly onDidDispose: Event<void>;
2828
readonly state: IObservable<ChatEditingSessionState>;
2929
readonly entries: IObservable<readonly IModifiedFileEntry[]>;
3030
readonly isVisible: boolean;
3131
show(): Promise<void>;
3232
accept(...uris: URI[]): Promise<void>;
3333
reject(...uris: URI[]): Promise<void>;
34-
dispose(): void;
34+
/**
35+
* Will lead to this object getting disposed
36+
*/
37+
stop(): Promise<void>;
3538
}
3639

3740
export const enum ModifiedFileEntryState {

src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,6 @@ suite('InteractiveChatController', function () {
160160
[ICommandService, new SyncDescriptor(TestCommandService)],
161161
[IChatEditingService, new class extends mock<IChatEditingService>() {
162162
override onDidCreateEditingSession: Event<IChatEditingSession> = Event.None;
163-
override onDidDisposeEditingSession: Event<IChatEditingSession> = Event.None;
164163
}],
165164
[IInlineChatSavingService, new class extends mock<IInlineChatSavingService>() {
166165
override markChanged(session: Session): void {

0 commit comments

Comments
 (0)