Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
agentsCommand: { name: 'agents' },
}));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
bugMemoryCommand: { name: 'bug-memory' },
}));
vi.mock('../ui/commands/chatCommand.js', () => ({
chatCommand: {
name: 'chat',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { commandsCommand } from '../ui/commands/commandsCommand.js';
Expand Down Expand Up @@ -121,6 +122,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
bugMemoryCommand,
{
...chatCommand,
subCommands: chatResumeSubCommands,
Expand Down
125 changes: 124 additions & 1 deletion packages/cli/src/ui/commands/bugCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { getVersion, type Config } from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import { MessageType } from '../types.js';
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';

const { memoryUsageMock } = vi.hoisted(() => ({
memoryUsageMock: vi.fn(() => ({
rss: 0,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
})),
}));

// Mock dependencies
vi.mock('open');
vi.mock('../utils/formatters.js');
vi.mock('../utils/memorySnapshot.js', () => ({
captureHeapSnapshot: vi.fn(),
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
stat: vi.fn().mockResolvedValue({ size: 4096 }),
};
});
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
Expand Down Expand Up @@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
memoryUsage: () => ({ rss: 0 }),
memoryUsage: memoryUsageMock,
},
}));

Expand All @@ -69,6 +92,13 @@ describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getVersion).mockResolvedValue('0.1.0');
vi.mocked(formatBytes).mockReturnValue('100 MB');
memoryUsageMock.mockReturnValue({
rss: 0,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.stubEnv('SANDBOX', 'gemini-test');
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
Expand Down Expand Up @@ -218,4 +248,97 @@ describe('bugCommand', () => {

expect(open).toHaveBeenCalledWith(expectedUrl);
});

const buildHighMemoryContext = (tempDir: string | undefined) =>
createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => false,
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
geminiClient: { getChat: () => ({ getHistory: () => [] }) },
},
},
});

it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
memoryUsageMock.mockReturnValue({
rss: 3 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);

const tempDir = path.join('/tmp', 'gemini-test');
const context = buildHighMemoryContext(tempDir);

if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(context, 'A memory bug');

const now = new Date('2024-01-01T00:00:00Z').getTime();
const expectedSnapshotPath = path.join(
tempDir,
`bug-memory-${now}.heapsnapshot`,
);
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);

const addItem = vi.mocked(context.ui.addItem);
const callOrder = addItem.mock.invocationCallOrder;
const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
// The URL message must precede the "capturing" message so the user sees
// the URL before the 20+ second snapshot starts.
expect(callOrder[0]).toBeLessThan(openOrder);
expect(callOrder[1]).toBeGreaterThan(openOrder);
expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
});

it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
memoryUsageMock.mockReturnValue({
rss: 1 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
const context = buildHighMemoryContext('/tmp/gemini-test');

if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(context, 'A light bug');

expect(captureHeapSnapshot).not.toHaveBeenCalled();
});

it('reports an error if the auto-capture fails but does not throw', async () => {
memoryUsageMock.mockReturnValue({
rss: 3 * 1024 * 1024 * 1024,
heapTotal: 0,
heapUsed: 0,
external: 0,
arrayBuffers: 0,
});
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
new Error('inspector failure'),
);
const context = buildHighMemoryContext('/tmp/gemini-test');

if (!bugCommand.action) throw new Error('Action is not defined');
await expect(
bugCommand.action(context, 'A memory bug'),
).resolves.toBeUndefined();

const addItem = vi.mocked(context.ui.addItem).mock.calls;
const lastCall = addItem[addItem.length - 1][0];
expect(lastCall.type).toBe(MessageType.ERROR);
expect(lastCall.text).toContain('inspector failure');
});
});
53 changes: 53 additions & 0 deletions packages/cli/src/ui/commands/bugCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
} from '@google/gemini-cli-core';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
import {
captureHeapSnapshot,
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
} from '../utils/memorySnapshot.js';
import { stat } from 'node:fs/promises';
import path from 'node:path';

export const bugCommand: SlashCommand = {
Expand Down Expand Up @@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
Date.now(),
);
}

const rss = process.memoryUsage().rss;
const tempDir = config?.storage?.getProjectTempDir();
if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
const snapshotPath = path.join(
tempDir,
`bug-memory-${Date.now()}.heapsnapshot`,
);
context.ui.addItem(
{
type: MessageType.INFO,
text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
},
Date.now(),
);
try {
const startedAt = Date.now();
await captureHeapSnapshot(snapshotPath);
const durationMs = Date.now() - startedAt;
let sizeText = '';
try {
const { size } = await stat(snapshotPath);
sizeText = ` (${formatBytes(size)})`;
} catch {
// Size reporting is best-effort; the snapshot itself was captured successfully.
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
},
Date.now(),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
debugLogger.error(
`Failed to capture heap snapshot for bug report: ${errorMessage}`,
);
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to capture heap snapshot: ${errorMessage}`,
},
Date.now(),
);
}
}
},
};

Expand Down
121 changes: 121 additions & 0 deletions packages/cli/src/ui/commands/bugMemoryCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'node:path';
import { bugMemoryCommand } from './bugMemoryCommand.js';
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { Config } from '@google/gemini-cli-core';

vi.mock('../utils/memorySnapshot.js', () => ({
captureHeapSnapshot: vi.fn(),
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
}));

vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
stat: vi.fn().mockResolvedValue({ size: 1234 }),
};
});

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
error: vi.fn(),
log: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
};
});

function makeContextWithTempDir(tempDir: string | undefined) {
return createMockCommandContext({
services: {
agentContext: {
config: {
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
} as unknown as Config,
},
},
});
}

describe('bugMemoryCommand', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
});

afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});

it('declares itself as a non-auto-executing built-in command', () => {
expect(bugMemoryCommand.name).toBe('bug-memory');
expect(bugMemoryCommand.autoExecute).toBe(false);
expect(bugMemoryCommand.description).toBeTruthy();
});

it('captures a heap snapshot and reports the file path', async () => {
const tempDir = path.join('/tmp', 'gemini-test');
const context = makeContextWithTempDir(tempDir);
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);

if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');

const expectedPath = path.join(
tempDir,
`bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
);
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);

const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
expect(addItemCalls).toHaveLength(2);
expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
expect(addItemCalls[0][0].text).toContain(expectedPath);
expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
expect(addItemCalls[1][0].text).toContain(expectedPath);
});

it('surfaces an error if capture fails', async () => {
const context = makeContextWithTempDir('/tmp/gemini-test');
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
new Error('inspector disconnected'),
);

if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');

const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
const lastCall = addItemCalls[addItemCalls.length - 1][0];
expect(lastCall.type).toBe(MessageType.ERROR);
expect(lastCall.text).toContain('inspector disconnected');
});

it('emits an error when no project temp directory is available', async () => {
const context = makeContextWithTempDir(undefined);

if (!bugMemoryCommand.action) throw new Error('Action missing');
await bugMemoryCommand.action(context, '');

expect(captureHeapSnapshot).not.toHaveBeenCalled();
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
expect(addItemCalls).toHaveLength(1);
expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
expect(addItemCalls[0][0].text).toContain('temp directory');
});
});
Loading