Skip to content

Commit b8323ca

Browse files
roblourensCopilot
andauthored
perf: Fix leak in rendering markdown/edits in thinking/subagent parts (#308939)
Co-authored-by: Copilot <copilot@github.com>
1 parent a4f5119 commit b8323ca

File tree

4 files changed

+158
-8
lines changed

4 files changed

+158
-8
lines changed

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ interface ILazyToolItem {
5858
interface ILazyMarkdownItem {
5959
kind: 'markdown';
6060
lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>;
61+
/**
62+
* True when the caller passed an eagerDisposable that has already been registered on this
63+
* subagent part. In that case, materializeLazyItem must not register the factory's returned
64+
* disposable again.
65+
*/
66+
eagerlyRegistered?: boolean;
6167
}
6268

6369
/**
@@ -864,25 +870,39 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
864870
/**
865871
* Appends a markdown item (e.g., an edit pill) to the subagent content part.
866872
* This is used to route codeblockUri parts with subAgentInvocationId to this subagent's container.
873+
*
874+
* When the caller has already created the content part eagerly (for example, a
875+
* pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass
876+
* that part as `eagerDisposable` so it is registered on this subagent part
877+
* immediately. Otherwise, if the subagent section is collapsed and the lazy item
878+
* is never materialized, the eagerly-created part would leak.
867879
*/
868880
public appendMarkdownItem(
869881
factory: () => { domNode: HTMLElement; disposable?: IDisposable },
870882
_codeblocksPartId: string | undefined,
871883
_markdown: IChatMarkdownContent,
872-
_originalParent?: HTMLElement
884+
_originalParent?: HTMLElement,
885+
eagerDisposable?: IDisposable,
873886
): void {
887+
// Register any caller-owned disposable up-front so it is always cleaned up
888+
// with this subagent part, even if the lazy item is never materialized.
889+
if (eagerDisposable) {
890+
this._register(eagerDisposable);
891+
}
892+
874893
// If expanded or has been expanded once, render immediately
875894
if (this.isExpanded() || this.hasExpandedOnce) {
876895
const result = factory();
877896
this.appendMarkdownItemToDOM(result.domNode);
878-
if (result.disposable) {
897+
if (result.disposable && result.disposable !== eagerDisposable) {
879898
this._register(result.disposable);
880899
}
881900
} else {
882901
// Defer rendering until expanded
883902
const item: ILazyMarkdownItem = {
884903
kind: 'markdown',
885904
lazy: new Lazy(factory),
905+
eagerlyRegistered: !!eagerDisposable,
886906
};
887907
this.lazyItems.push(item);
888908
}
@@ -1079,7 +1099,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen
10791099
} else if (item.kind === 'markdown') {
10801100
const result = item.lazy.value;
10811101
this.appendMarkdownItemToDOM(result.domNode);
1082-
if (result.disposable) {
1102+
if (result.disposable && !item.eagerlyRegistered) {
10831103
this._register(result.disposable);
10841104
}
10851105
} else if (item.kind === 'hook') {

src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,13 +1330,22 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
13301330
* Appends a tool invocation or content item to the thinking group.
13311331
* The factory is called lazily - only when the thinking section is expanded.
13321332
* If already expanded, the factory is called immediately.
1333+
*
1334+
* When the caller has already created the content part eagerly (for example, a
1335+
* pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass
1336+
* that part as `eagerDisposable` so it is registered on this thinking part
1337+
* immediately. Otherwise, if the thinking section is collapsed and the lazy item
1338+
* is never materialized (because the user never expands it), the eagerly-created
1339+
* part would leak: its disposable is only referenced from inside the factory's
1340+
* closure, which nothing ever calls.
13331341
*/
13341342
public appendItem(
13351343
factory: () => { domNode: HTMLElement; disposable?: IDisposable },
13361344
toolInvocationId?: string,
13371345
toolInvocationOrMarkdown?: IChatToolInvocation | IChatToolInvocationSerialized | IChatMarkdownContent,
13381346
originalParent?: HTMLElement,
1339-
onDidChangeDiff?: Event<IEditSessionDiffStats>
1347+
onDidChangeDiff?: Event<IEditSessionDiffStats>,
1348+
eagerDisposable?: IDisposable,
13401349
): void {
13411350
this.processPendingRemovals();
13421351

@@ -1352,6 +1361,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
13521361
}));
13531362
}
13541363

1364+
// Register any caller-owned disposable up-front so it is always cleaned up
1365+
// with this thinking part, even if the lazy item is never materialized.
1366+
if (eagerDisposable) {
1367+
this._register(eagerDisposable);
1368+
}
1369+
13551370
// get random message based on tool type
13561371
if (this.workingSpinnerLabel) {
13571372
const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal';
@@ -1379,7 +1394,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks):
13791394
toolInvocationId,
13801395
toolInvocationOrMarkdown,
13811396
originalParent,
1382-
isHook: !toolInvocationOrMarkdown && !!toolInvocationId
1397+
isHook: !toolInvocationOrMarkdown && !!toolInvocationId,
13831398
};
13841399
this.lazyItems.push(item);
13851400
}

src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2689,7 +2689,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
26892689
() => ({ domNode: markdownPart.domNode, disposable: markdownPart }),
26902690
markdownPart.codeblocksPartId,
26912691
markdown,
2692-
templateData.value
2692+
templateData.value,
2693+
markdownPart,
26932694
);
26942695
return subagentPart;
26952696
}
@@ -2709,7 +2710,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
27092710
markdownPart.codeblocksPartId,
27102711
markdown,
27112712
templateData.value,
2712-
markdownPart.onDidChangeDiff
2713+
markdownPart.onDidChangeDiff,
2714+
markdownPart,
27132715
);
27142716
}
27152717

@@ -2718,7 +2720,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
27182720

27192721
if (this.shouldPinPart(markdown, context.element) && isComplete) {
27202722
if (lastThinking && markdownPart?.domNode) {
2721-
// Factory wrapping already-created markdown part
2723+
// Factory wrapping already-created markdown part.
2724+
// No eagerDisposable needed here because the markdownPart is returned
2725+
// from this method and tracked directly in renderedParts, so it will
2726+
// be disposed by clearRenderedParts.
27222727
lastThinking.appendItem(
27232728
() => ({ domNode: markdownPart.domNode, disposable: markdownPart }),
27242729
markdownPart.codeblocksPartId,

src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,4 +1606,114 @@ suite('ChatThinkingContentPart', () => {
16061606
assert.strictEqual(diffContainer, null, 'Should not render diff container when no diffs exist');
16071607
});
16081608
});
1609+
1610+
suite('eagerDisposable lifecycle', () => {
1611+
setup(() => {
1612+
mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed);
1613+
});
1614+
1615+
test('eagerDisposable is disposed when thinking part is disposed even if factory was never called', () => {
1616+
const content = createThinkingPart('**Working**');
1617+
const context = createMockRenderContext(false);
1618+
1619+
const part = instantiationService.createInstance(
1620+
ChatThinkingContentPart,
1621+
content,
1622+
context,
1623+
mockMarkdownRenderer,
1624+
false
1625+
);
1626+
1627+
mainWindow.document.body.appendChild(part.domNode);
1628+
1629+
let disposed = false;
1630+
const eagerDisposable = toDisposable(() => { disposed = true; });
1631+
const factory = () => ({
1632+
domNode: $('div.test-item'),
1633+
disposable: eagerDisposable,
1634+
});
1635+
1636+
// Append while collapsed — factory is NOT called
1637+
part.appendItem(factory, 'test-tool', undefined, undefined, undefined, eagerDisposable);
1638+
1639+
assert.strictEqual(disposed, false, 'Should not be disposed yet');
1640+
1641+
// Dispose the thinking part without ever expanding
1642+
part.domNode.remove();
1643+
part.dispose();
1644+
1645+
assert.strictEqual(disposed, true, 'eagerDisposable should be disposed with the thinking part');
1646+
});
1647+
1648+
test('eagerDisposable is disposed when thinking part is disposed after factory was called', () => {
1649+
const content = createThinkingPart('**Working**\nSome detailed analysis');
1650+
const context = createMockRenderContext(false);
1651+
1652+
const part = instantiationService.createInstance(
1653+
ChatThinkingContentPart,
1654+
content,
1655+
context,
1656+
mockMarkdownRenderer,
1657+
false
1658+
);
1659+
1660+
mainWindow.document.body.appendChild(part.domNode);
1661+
1662+
let disposed = false;
1663+
const eagerDisposable = toDisposable(() => { disposed = true; });
1664+
const factory = () => ({
1665+
domNode: $('div.test-item'),
1666+
disposable: eagerDisposable,
1667+
});
1668+
1669+
// Append while collapsed
1670+
part.appendItem(factory, 'test-tool', undefined, undefined, undefined, eagerDisposable);
1671+
1672+
// Expand to trigger factory call
1673+
const button = part.domNode.querySelector('.monaco-button') as HTMLElement;
1674+
button?.click();
1675+
1676+
assert.strictEqual(disposed, false, 'Should not be disposed yet');
1677+
1678+
// Dispose
1679+
part.domNode.remove();
1680+
part.dispose();
1681+
1682+
assert.strictEqual(disposed, true, 'eagerDisposable should be disposed even after being materialized');
1683+
});
1684+
1685+
test('appendItem without eagerDisposable disposes factory result on thinking part disposal', () => {
1686+
const content = createThinkingPart('**Working**\nSome detailed analysis');
1687+
const context = createMockRenderContext(false);
1688+
1689+
const part = instantiationService.createInstance(
1690+
ChatThinkingContentPart,
1691+
content,
1692+
context,
1693+
mockMarkdownRenderer,
1694+
false
1695+
);
1696+
1697+
mainWindow.document.body.appendChild(part.domNode);
1698+
1699+
// Expand first so factory is called immediately
1700+
const button = part.domNode.querySelector('.monaco-button') as HTMLElement;
1701+
button?.click();
1702+
1703+
let disposed = false;
1704+
const factory = () => ({
1705+
domNode: $('div.test-item'),
1706+
disposable: toDisposable(() => { disposed = true; }),
1707+
});
1708+
1709+
part.appendItem(factory, 'test-tool');
1710+
1711+
assert.strictEqual(disposed, false, 'Should not be disposed yet');
1712+
1713+
part.domNode.remove();
1714+
part.dispose();
1715+
1716+
assert.strictEqual(disposed, true, 'Factory disposable should be disposed with thinking part');
1717+
});
1718+
});
16091719
});

0 commit comments

Comments
 (0)