Skip to content

Commit 3d4956a

Browse files
perf(core): fix OOM crash in long-running sessions (#19608)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent 9dc6898 commit 3d4956a

4 files changed

Lines changed: 35 additions & 13 deletions

File tree

packages/core/src/core/geminiChat.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,13 @@ export class GeminiChat {
691691
const history = curated
692692
? extractCuratedHistory(this.history)
693693
: this.history;
694-
// Deep copy the history to avoid mutating the history outside of the
695-
// chat session.
696-
return structuredClone(history);
694+
// Return a shallow copy of the array to prevent callers from mutating
695+
// the internal history array (push/pop/splice). Content objects are
696+
// shared references — callers MUST NOT mutate them in place.
697+
// This replaces a prior structuredClone() which deep-copied the entire
698+
// conversation on every call, causing O(n) memory pressure per turn
699+
// that compounded into OOM crashes in long-running sessions.
700+
return [...history];
697701
}
698702

699703
/**

packages/core/src/core/turn.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export class Turn {
241241
readonly pendingToolCalls: ToolCallRequestInfo[] = [];
242242
private debugResponses: GenerateContentResponse[] = [];
243243
private pendingCitations = new Set<string>();
244+
private cachedResponseText: string | undefined = undefined;
244245
finishReason: FinishReason | undefined = undefined;
245246

246247
constructor(
@@ -432,11 +433,15 @@ export class Turn {
432433
/**
433434
* Get the concatenated response text from all responses in this turn.
434435
* This extracts and joins all text content from the model's responses.
436+
* The result is cached since this is called multiple times per turn.
435437
*/
436438
getResponseText(): string {
437-
return this.debugResponses
438-
.map((response) => getResponseText(response))
439-
.filter((text): text is string => text !== null)
440-
.join(' ');
439+
if (this.cachedResponseText === undefined) {
440+
this.cachedResponseText = this.debugResponses
441+
.map((response) => getResponseText(response))
442+
.filter((text): text is string => text !== null)
443+
.join(' ');
444+
}
445+
return this.cachedResponseText;
441446
}
442447
}

packages/core/src/utils/events.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ type EventBacklogItem = {
232232

233233
export class CoreEventEmitter extends EventEmitter<CoreEvents> {
234234
private _eventBacklog: EventBacklogItem[] = [];
235+
private _backlogHead = 0;
235236
private static readonly MAX_BACKLOG_SIZE = 10000;
236237

237238
constructor() {
@@ -243,8 +244,17 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
243244
...args: CoreEvents[K]
244245
): void {
245246
if (this.listenerCount(event) === 0) {
246-
if (this._eventBacklog.length >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
247-
this._eventBacklog.shift();
247+
const backlogSize = this._eventBacklog.length - this._backlogHead;
248+
if (backlogSize >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
249+
// Evict oldest entry. Use a head pointer instead of shift() to avoid
250+
// O(n) array reindexing on every eviction at capacity.
251+
(this._eventBacklog as unknown[])[this._backlogHead] = undefined;
252+
this._backlogHead++;
253+
// Compact once dead entries exceed half capacity to bound memory
254+
if (this._backlogHead >= CoreEventEmitter.MAX_BACKLOG_SIZE / 2) {
255+
this._eventBacklog = this._eventBacklog.slice(this._backlogHead);
256+
this._backlogHead = 0;
257+
}
248258
}
249259
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
250260
this._eventBacklog.push({ event, args } as EventBacklogItem);
@@ -391,9 +401,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
391401
* subscribes.
392402
*/
393403
drainBacklogs(): void {
394-
const backlog = [...this._eventBacklog];
395-
this._eventBacklog.length = 0; // Clear in-place
396-
for (const item of backlog) {
404+
const backlog = this._eventBacklog;
405+
const head = this._backlogHead;
406+
this._eventBacklog = [];
407+
this._backlogHead = 0;
408+
for (let i = head; i < backlog.length; i++) {
409+
const item = backlog[i];
410+
if (item === undefined) continue;
397411
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
398412
(this.emit as (event: keyof CoreEvents, ...args: unknown[]) => boolean)(
399413
item.event,

packages/core/src/utils/nextSpeakerChecker.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@ export async function checkNextSpeaker(
8787
lastComprehensiveMessage.parts &&
8888
lastComprehensiveMessage.parts.length === 0
8989
) {
90-
lastComprehensiveMessage.parts.push({ text: '' });
9190
return {
9291
reasoning:
9392
'The last message was a filler model message with no content (nothing for user to act on), model should speak next.',

0 commit comments

Comments
 (0)