mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
feat(cli): add /bug-memory command and auto-capture heap snapshot in /bug (#25639)
This commit is contained in:
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
|
|||||||
agentsCommand: { name: 'agents' },
|
agentsCommand: { name: 'agents' },
|
||||||
}));
|
}));
|
||||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||||
|
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
|
||||||
|
bugMemoryCommand: { name: 'bug-memory' },
|
||||||
|
}));
|
||||||
vi.mock('../ui/commands/chatCommand.js', () => ({
|
vi.mock('../ui/commands/chatCommand.js', () => ({
|
||||||
chatCommand: {
|
chatCommand: {
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
|||||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||||
import { authCommand } from '../ui/commands/authCommand.js';
|
import { authCommand } from '../ui/commands/authCommand.js';
|
||||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||||
|
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
|
||||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
||||||
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||||
authCommand,
|
authCommand,
|
||||||
bugCommand,
|
bugCommand,
|
||||||
|
bugMemoryCommand,
|
||||||
{
|
{
|
||||||
...chatCommand,
|
...chatCommand,
|
||||||
subCommands: chatResumeSubCommands,
|
subCommands: chatResumeSubCommands,
|
||||||
|
|||||||
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
|||||||
import { getVersion, type Config } from '@google/gemini-cli-core';
|
import { getVersion, type Config } from '@google/gemini-cli-core';
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
import { formatBytes } from '../utils/formatters.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
|
// Mock dependencies
|
||||||
vi.mock('open');
|
vi.mock('open');
|
||||||
vi.mock('../utils/formatters.js');
|
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) => {
|
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||||
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
|
|||||||
version: 'v20.0.0',
|
version: 'v20.0.0',
|
||||||
// Keep other necessary process properties if needed by other parts of the code
|
// Keep other necessary process properties if needed by other parts of the code
|
||||||
env: process.env,
|
env: process.env,
|
||||||
memoryUsage: () => ({ rss: 0 }),
|
memoryUsage: memoryUsageMock,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||||
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
||||||
|
memoryUsageMock.mockReturnValue({
|
||||||
|
rss: 0,
|
||||||
|
heapTotal: 0,
|
||||||
|
heapUsed: 0,
|
||||||
|
external: 0,
|
||||||
|
arrayBuffers: 0,
|
||||||
|
});
|
||||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||||
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
|
|||||||
|
|
||||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import {
|
|||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
import { exportHistoryToFile } from '../utils/historyExportUtils.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';
|
import path from 'node:path';
|
||||||
|
|
||||||
export const bugCommand: SlashCommand = {
|
export const bugCommand: SlashCommand = {
|
||||||
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
|
|||||||
Date.now(),
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { debugLogger } from '@google/gemini-cli-core';
|
||||||
|
import {
|
||||||
|
type CommandContext,
|
||||||
|
type SlashCommand,
|
||||||
|
CommandKind,
|
||||||
|
} from './types.js';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import { formatBytes } from '../utils/formatters.js';
|
||||||
|
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||||
|
|
||||||
|
export const bugMemoryCommand: SlashCommand = {
|
||||||
|
name: 'bug-memory',
|
||||||
|
description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: false,
|
||||||
|
action: async (context: CommandContext): Promise<void> => {
|
||||||
|
const tempDir =
|
||||||
|
context.services.agentContext?.config?.storage?.getProjectTempDir();
|
||||||
|
if (!tempDir) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||||
|
);
|
||||||
|
const rss = process.memoryUsage().rss;
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await captureHeapSnapshot(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
debugLogger.error(`Failed to capture heap snapshot: ${message}`);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to capture heap snapshot: ${message}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
let sizeText = '';
|
||||||
|
try {
|
||||||
|
const { size } = await stat(filePath);
|
||||||
|
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${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import {
|
||||||
|
captureHeapSnapshot,
|
||||||
|
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
|
||||||
|
} from './memorySnapshot.js';
|
||||||
|
|
||||||
|
const { mkdirMock, pipelineMock, getHeapSnapshotMock, createWriteStreamMock } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
mkdirMock: vi.fn(async () => undefined),
|
||||||
|
pipelineMock: vi.fn(async () => undefined),
|
||||||
|
getHeapSnapshotMock: vi.fn(),
|
||||||
|
createWriteStreamMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||||
|
return { ...actual, mkdir: mkdirMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:fs')>();
|
||||||
|
return { ...actual, createWriteStream: createWriteStreamMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:v8', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:v8')>();
|
||||||
|
return { ...actual, getHeapSnapshot: getHeapSnapshotMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('node:stream/promises', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:stream/promises')>();
|
||||||
|
return { ...actual, pipeline: pipelineMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('captureHeapSnapshot', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mkdirMock.mockClear();
|
||||||
|
pipelineMock.mockClear();
|
||||||
|
getHeapSnapshotMock.mockClear().mockReturnValue(Readable.from([]));
|
||||||
|
createWriteStreamMock
|
||||||
|
.mockClear()
|
||||||
|
.mockReturnValue({ write: vi.fn(), end: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports the 2 GB auto-capture threshold', () => {
|
||||||
|
expect(MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the target directory and pipelines the V8 snapshot to disk', async () => {
|
||||||
|
const target = '/tmp/gemini-test/snapshot.heapsnapshot';
|
||||||
|
|
||||||
|
await captureHeapSnapshot(target);
|
||||||
|
|
||||||
|
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gemini-test', {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
expect(getHeapSnapshotMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createWriteStreamMock).toHaveBeenCalledWith(target);
|
||||||
|
expect(pipelineMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pipelineMock).toHaveBeenCalledWith(
|
||||||
|
getHeapSnapshotMock.mock.results[0].value,
|
||||||
|
createWriteStreamMock.mock.results[0].value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates pipeline failures to the caller', async () => {
|
||||||
|
pipelineMock.mockRejectedValueOnce(new Error('write failed'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
captureHeapSnapshot('/tmp/gemini-test/fail.heapsnapshot'),
|
||||||
|
).rejects.toThrow('write failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { getHeapSnapshot } from 'node:v8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RSS threshold at which `/bug` auto-captures a heap snapshot.
|
||||||
|
*/
|
||||||
|
export const MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a V8 heap snapshot from the current process and write it to disk.
|
||||||
|
*
|
||||||
|
* `v8.getHeapSnapshot()` returns a Readable stream whose producer is V8's
|
||||||
|
* internal snapshot generator. Piping it through `node:stream/promises`'
|
||||||
|
* `pipeline` propagates backpressure end-to-end, so even a multi-gigabyte
|
||||||
|
* heap is written without buffering the serialized snapshot in memory.
|
||||||
|
* Nothing is exposed over a debugger port.
|
||||||
|
*/
|
||||||
|
export async function captureHeapSnapshot(filePath: string): Promise<void> {
|
||||||
|
await mkdir(dirname(filePath), { recursive: true });
|
||||||
|
await pipeline(getHeapSnapshot(), createWriteStream(filePath));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user