diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 0d5442038a0..7660f914cdd 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -11,6 +11,11 @@ import { initStartupProfiler } from './src/utils/startupProfiler.js'; // Must run before any other imports to capture the earliest possible T0. initStartupProfiler(); +import { initCpuProfiler } from './src/utils/cpuProfiler.js'; +// Initialize early to register SIGUSR1 handler and start recording when +// QWEN_CODE_CPU_PROFILE=1, capturing as much of the startup as possible. +initCpuProfiler(); + import './src/gemini.js'; import { main } from './src/gemini.js'; import { FatalError } from '@qwen-code/qwen-code-core'; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 9061fa2b0e2..b4949ac4de0 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1898,6 +1898,8 @@ export default { 'Open the memory manager.': 'Open the memory manager.', 'Show current process memory diagnostics': 'Show current process memory diagnostics', + 'Record a CPU profile for Chrome DevTools analysis': + 'Record a CPU profile for Chrome DevTools analysis', 'Save a durable memory to the memory system.': 'Save a durable memory to the memory system.', 'Ask a quick side question without affecting the main conversation': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 44c2116887a..c845a1af45d 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1485,6 +1485,8 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': '打開記憶管理器。', 'Show current process memory diagnostics': '顯示目前程序的內存診斷。', + 'Record a CPU profile for Chrome DevTools analysis': + '錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析', 'Save a durable memory to the memory system.': '將持久記憶保存到記憶系統。', 'Ask a quick side question without affecting the main conversation': '在不影響主對話的情況下快速提問旁支問題', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index eac5e259413..578202f314f 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1720,6 +1720,8 @@ export default { 'Loading suggestions...': '正在加载建议...', 'Open the memory manager.': '打开记忆管理器。', 'Show current process memory diagnostics': '显示当前进程的内存诊断。', + 'Record a CPU profile for Chrome DevTools analysis': + '录制 CPU 性能分析文件,用于 Chrome DevTools 分析', 'Save a durable memory to the memory system.': '将一条持久记忆保存到记忆系统。', 'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。', diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 315ebc8cbcc..01f51be2941 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -177,10 +177,14 @@ describe('doctorCommand', () => { it('should complete memory subcommand names', async () => { await expect(doctorCommand.completion!(mockContext, '')).resolves.toEqual([ 'memory', + 'cpu-profile', ]); await expect( doctorCommand.completion!(mockContext, 'mem'), ).resolves.toEqual(['memory']); + await expect( + doctorCommand.completion!(mockContext, 'cpu'), + ).resolves.toEqual(['cpu-profile']); await expect(doctorCommand.completion!(mockContext, 'x')).resolves.toEqual( [], ); @@ -1049,6 +1053,8 @@ describe('doctorCommand', () => { }); it('should advertise the memory subcommand on the parent doctor argumentHint', () => { - expect(doctorCommand.argumentHint).toBe('[memory] [--sample] [--snapshot]'); + expect(doctorCommand.argumentHint).toBe( + '[memory|cpu-profile] [--sample] [--snapshot] [--duration]', + ); }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index bb90c389974..3a3bd936375 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -16,6 +16,11 @@ import { isHighHeapPressure, writeMemoryHeapSnapshot, } from '../../utils/memoryDiagnostics.js'; +import { + isCpuProfileRecording, + startCpuProfile, + stopCpuProfile, +} from '../../utils/cpuProfiler.js'; import { t } from '../../i18n/index.js'; import { collectMemoryDiagnostics, @@ -24,7 +29,8 @@ import { import { formatMemoryUsage } from '../utils/formatters.js'; const MEMORY_SUBCOMMAND = 'memory'; -const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND] as const; +const CPU_PROFILE_SUBCOMMAND = 'cpu-profile'; +const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND, CPU_PROFILE_SUBCOMMAND] as const; function getHeapSnapshotSensitiveDataWarning(): string { return t( 'Heap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.', @@ -49,12 +55,14 @@ export const doctorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - argumentHint: '[memory] [--sample] [--snapshot]', + argumentHint: '[memory|cpu-profile] [--sample] [--snapshot] [--duration]', examples: [ '/doctor', '/doctor memory', '/doctor memory --sample', '/doctor memory --snapshot', + '/doctor cpu-profile', + '/doctor cpu-profile --duration 10', ], completion: async (_context, partialArg) => { const trimmed = partialArg.trimStart(); @@ -181,6 +189,10 @@ export const doctorCommand: SlashCommand = { }; } + if (subCommand === CPU_PROFILE_SUBCOMMAND) { + return cpuProfileDoctorAction(context, subCommandArgs.slice(1).join(' ')); + } + if (executionMode === 'interactive') { context.ui.setPendingItem({ type: 'info', @@ -233,6 +245,16 @@ export const doctorCommand: SlashCommand = { argumentHint: '[--json] [--sample] [--snapshot]', action: memoryDoctorAction, }, + { + name: 'cpu-profile', + get description() { + return t('Record a CPU profile for Chrome DevTools analysis'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + argumentHint: '[--duration ]', + action: cpuProfileDoctorAction, + }, ], }; @@ -382,3 +404,179 @@ function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string { ); return lines.join('\n'); } + +// --------------------------------------------------------------------------- +// /doctor cpu-profile +// --------------------------------------------------------------------------- + +const CPU_PROFILE_USAGE_HINT = '/doctor cpu-profile [--duration ]'; +const DEFAULT_CPU_PROFILE_DURATION_SEC = 30; +const MAX_CPU_PROFILE_DURATION_SEC = 300; + +async function cpuProfileDoctorAction( + context: CommandContext, + args = '', +): Promise { + const executionMode = context.executionMode ?? 'interactive'; + const abortSignal = context.abortSignal; + + if (abortSignal?.aborted) return; + + const tokens = args.trim().split(/\s+/).filter(Boolean); + + // Parse --duration flag + let durationSec = DEFAULT_CPU_PROFILE_DURATION_SEC; + const durationIdx = tokens.indexOf('--duration'); + if (durationIdx !== -1) { + const rawVal = tokens[durationIdx + 1]; + const val = rawVal ? parseInt(rawVal, 10) : NaN; + if ( + !Number.isFinite(val) || + val < 1 || + val > MAX_CPU_PROFILE_DURATION_SEC + ) { + const errorMsg = `${t('Duration must be between 1 and {max} seconds', { max: String(MAX_CPU_PROFILE_DURATION_SEC) })}. ${t('Usage')}: ${CPU_PROFILE_USAGE_HINT}`; + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'error', text: errorMsg }, Date.now()); + return; + } + return { type: 'message', messageType: 'error', content: errorMsg }; + } + durationSec = val; + } + + // Validate unknown arguments + const knownTokens = new Set(['--duration']); + const unknown = tokens.filter((token, idx) => { + if (knownTokens.has(token)) return false; + // Skip the value after --duration + if (idx > 0 && tokens[idx - 1] === '--duration') return false; + return true; + }); + if (unknown.length > 0) { + const errorMsg = `${t('Unknown argument(s)')}: ${unknown.join(', ')}. ${t('Usage')}: ${CPU_PROFILE_USAGE_HINT}`; + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'error', text: errorMsg }, Date.now()); + return; + } + return { type: 'message', messageType: 'error', content: errorMsg }; + } + + // Check if already recording + if (isCpuProfileRecording()) { + const errorMsg = + process.platform === 'win32' + ? t('CPU profiling is already in progress. Wait for it to complete.') + : t( + 'CPU profiling is already in progress. Send SIGUSR1 or wait for it to complete.', + ); + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'error', text: errorMsg }, Date.now()); + return; + } + return { type: 'message', messageType: 'error', content: errorMsg }; + } + + // Start recording + const startResult = await startCpuProfile(); + if (!startResult.ok) { + if (executionMode === 'interactive') { + context.ui.addItem( + { type: 'error', text: startResult.error }, + Date.now(), + ); + return; + } + return { + type: 'message', + messageType: 'error', + content: startResult.error, + }; + } + + if (abortSignal?.aborted) { + const abortResult = await stopCpuProfile(); + if (abortResult.ok) { + const msg = t('CPU profile aborted early. Profile saved: {path}', { + path: abortResult.filePath, + }); + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: msg }, Date.now()); + } + } else { + const msg = `${t('CPU profile aborted but failed to stop cleanly')}: ${abortResult.error}`; + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'error', text: msg }, Date.now()); + } + } + return; + } + + // Show progress in interactive mode + if (executionMode === 'interactive') { + context.ui.setPendingItem({ + type: 'info', + text: t('Recording CPU profile for {duration}s...', { + duration: String(durationSec), + }), + }); + } + + // Wait for duration or abort. Timer is NOT unref'd so non-interactive + // mode keeps the process alive for the full recording window. + await new Promise((resolve) => { + const timer = setTimeout(resolve, durationSec * 1000); + if (abortSignal) { + abortSignal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + } + }); + + if (executionMode === 'interactive') { + context.ui.setPendingItem(null); + } + + // Stop and write profile. If profiler is no longer recording (e.g., SIGUSR1 + // stopped it during the wait), treat as success — the profile was already written. + const stopResult = await stopCpuProfile(); + if (!stopResult.ok) { + const alreadyStopped = stopResult.error.includes('not recording'); + if (alreadyStopped) { + const infoMsg = + process.platform === 'win32' + ? t( + 'CPU profile was stopped externally. Check ~/.qwen/cpu-profiles/ for the output.', + ) + : t( + 'CPU profile was stopped externally (e.g., via SIGUSR1). Check ~/.qwen/cpu-profiles/ for the output.', + ); + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: infoMsg }, Date.now()); + return; + } + return { type: 'message', messageType: 'info', content: infoMsg }; + } + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'error', text: stopResult.error }, Date.now()); + return; + } + return { type: 'message', messageType: 'error', content: stopResult.error }; + } + + const successMsg = `${t('CPU profile written:')} ${stopResult.filePath}\n${t('Open in Chrome DevTools → Performance tab → Load profile')}`; + if (executionMode === 'interactive') { + context.ui.addItem({ type: 'info', text: successMsg }, Date.now()); + return; + } + return { type: 'message', messageType: 'info', content: successMsg }; +} diff --git a/packages/cli/src/utils/cpuProfiler.test.ts b/packages/cli/src/utils/cpuProfiler.test.ts new file mode 100644 index 00000000000..e3def5ab74c --- /dev/null +++ b/packages/cli/src/utils/cpuProfiler.test.ts @@ -0,0 +1,357 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock cleanup.ts to avoid pulling in @qwen-code/qwen-code-core dependency chain +vi.mock('./cleanup.js', () => ({ + registerCleanup: vi.fn(), +})); + +import { + _resetCpuProfilerForTest, + _setSessionFactoryForTest, + clearCpuProfileRateLimit, + isCpuProfileRecording, + startCpuProfile, + stopCpuProfile, +} from './cpuProfiler.js'; + +function createMockSession() { + const mockProfile = { + nodes: [ + { + id: 1, + callFrame: { + functionName: 'test', + scriptId: '1', + url: '', + lineNumber: 0, + columnNumber: 0, + }, + hitCount: 10, + children: [], + }, + ], + startTime: 0, + endTime: 1000000, + samples: [1], + timeDeltas: [100], + }; + + const post = vi.fn().mockImplementation((method: string) => { + if (method === 'Profiler.stop') { + return Promise.resolve({ profile: mockProfile }); + } + return Promise.resolve(undefined); + }); + const connect = vi.fn(); + const disconnect = vi.fn(); + + return { post, connect, disconnect }; +} + +describe('cpuProfiler', () => { + let tmpDir: string; + let mockSession: ReturnType; + + beforeEach(() => { + _resetCpuProfilerForTest(); + clearCpuProfileRateLimit(); + + mockSession = createMockSession(); + _setSessionFactoryForTest(async () => mockSession); + + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-profiler-test-')); + }); + + afterEach(() => { + _resetCpuProfilerForTest(); + _setSessionFactoryForTest(null); + vi.restoreAllMocks(); + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + describe('isCpuProfileRecording', () => { + it('returns false when not recording', () => { + expect(isCpuProfileRecording()).toBe(false); + }); + + it('returns true when recording', async () => { + await startCpuProfile(); + expect(isCpuProfileRecording()).toBe(true); + }); + }); + + describe('startCpuProfile', () => { + it('starts profiling successfully', async () => { + const result = await startCpuProfile(); + expect(result).toEqual({ ok: true }); + expect(mockSession.post).toHaveBeenCalledWith('Profiler.enable'); + expect(mockSession.post).toHaveBeenCalledWith( + 'Profiler.setSamplingInterval', + { interval: 1000 }, + ); + expect(mockSession.post).toHaveBeenCalledWith('Profiler.start'); + }); + + it('accepts custom sampling interval', async () => { + await startCpuProfile({ samplingInterval: 500 }); + expect(mockSession.post).toHaveBeenCalledWith( + 'Profiler.setSamplingInterval', + { interval: 500 }, + ); + }); + + it('returns error when already recording', async () => { + await startCpuProfile(); + const result = await startCpuProfile(); + expect(result).toEqual({ + ok: false, + error: 'CPU profiling is already in progress.', + }); + }); + + it('returns error and resets state on session failure', async () => { + _setSessionFactoryForTest(async () => { + throw new Error('Connection refused'); + }); + + const result = await startCpuProfile(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('Connection refused'); + } + expect(isCpuProfileRecording()).toBe(false); + }); + }); + + describe('stopCpuProfile', () => { + it('stops and writes profile file', async () => { + await startCpuProfile(); + const result = await stopCpuProfile({ outputDir: tmpDir }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.filePath).toMatch(/qwen-code-cpu-\d+-.*\.cpuprofile$/); + expect(fs.existsSync(result.filePath)).toBe(true); + + const content = JSON.parse(fs.readFileSync(result.filePath, 'utf8')); + expect(content.nodes).toBeDefined(); + expect(content.startTime).toBeDefined(); + } + }); + + it('returns error when not recording', async () => { + const result = await stopCpuProfile({ outputDir: tmpDir }); + expect(result).toEqual({ + ok: false, + error: 'CPU profiler is not recording.', + }); + }); + + it('calls Profiler.stop and Profiler.disable', async () => { + await startCpuProfile(); + mockSession.post.mockClear(); + await stopCpuProfile({ outputDir: tmpDir }); + + expect(mockSession.post).toHaveBeenCalledWith('Profiler.stop'); + expect(mockSession.post).toHaveBeenCalledWith('Profiler.disable'); + }); + + it('sets file permissions to 0o600', async () => { + await startCpuProfile(); + const result = await stopCpuProfile({ outputDir: tmpDir }); + + if (result.ok && process.platform !== 'win32') { + const stats = fs.statSync(result.filePath); + expect(stats.mode & 0o777).toBe(0o600); + } + }); + }); + + describe('rate limiting', () => { + it('enforces rate limit between writes', async () => { + const now = new Date('2026-05-29T10:00:00.000Z'); + + await startCpuProfile(); + const first = await stopCpuProfile({ outputDir: tmpDir, now }); + expect(first.ok).toBe(true); + + // Second write within rate limit window + await startCpuProfile(); + const second = await stopCpuProfile({ + outputDir: tmpDir, + now: new Date(now.getTime() + 5000), // 5s later, within 30s limit + }); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain('rate limit'); + } + }); + + it('allows write after rate limit expires', async () => { + const now = new Date('2026-05-29T10:00:00.000Z'); + + await startCpuProfile(); + await stopCpuProfile({ outputDir: tmpDir, now }); + + // After rate limit window + await startCpuProfile(); + const result = await stopCpuProfile({ + outputDir: tmpDir, + now: new Date(now.getTime() + 31000), // 31s later + }); + expect(result.ok).toBe(true); + }); + + it('resets state to idle when rate-limited so user can retry', async () => { + const now = new Date('2026-05-29T10:00:00.000Z'); + + await startCpuProfile(); + await stopCpuProfile({ outputDir: tmpDir, now }); + + // Start a new recording, then try to stop within rate limit window + await startCpuProfile(); + const rateLimited = await stopCpuProfile({ + outputDir: tmpDir, + now: new Date(now.getTime() + 5000), + }); + expect(rateLimited.ok).toBe(false); + + // State should be reset — a new startCpuProfile() must succeed + const restart = await startCpuProfile(); + expect(restart.ok).toBe(true); + }); + }); + + describe('old profile cleanup', () => { + it('removes old profiles beyond max count', async () => { + // Create 5 existing profiles + for (let i = 0; i < 5; i++) { + const name = `qwen-code-cpu-99999-2026-05-29T0${i}-00-00-000Z.cpuprofile`; + fs.writeFileSync(path.join(tmpDir, name), '{}'); + // Stagger mtime so sort is deterministic + const mtime = new Date(Date.now() - (5 - i) * 1000); + fs.utimesSync(path.join(tmpDir, name), mtime, mtime); + } + + clearCpuProfileRateLimit(); + await startCpuProfile(); + const result = await stopCpuProfile({ + outputDir: tmpDir, + maxProfiles: 5, + }); + expect(result.ok).toBe(true); + + const files = fs + .readdirSync(tmpDir) + .filter((f) => f.endsWith('.cpuprofile')); + // Should have at most 5 files (new one replaces oldest) + expect(files.length).toBeLessThanOrEqual(5); + }); + }); + + describe('conflict handling', () => { + it('rejects second start while recording', async () => { + const first = await startCpuProfile(); + expect(first.ok).toBe(true); + + const second = await startCpuProfile(); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.error).toContain('already in progress'); + } + }); + + it('resets state after stop so new recording can start', async () => { + await startCpuProfile(); + await stopCpuProfile({ outputDir: tmpDir }); + + clearCpuProfileRateLimit(); + const result = await startCpuProfile(); + expect(result.ok).toBe(true); + }); + }); + + describe('initCpuProfiler', () => { + it('is idempotent — calling twice does not error', async () => { + const { initCpuProfiler } = await import('./cpuProfiler.js'); + // First call + initCpuProfiler(); + // Second call should be a no-op + initCpuProfiler(); + // No error thrown means success + }); + + it('does not start recording when env var is unset', async () => { + _resetCpuProfilerForTest(); + delete process.env['QWEN_CODE_CPU_PROFILE']; + const { initCpuProfiler } = await import('./cpuProfiler.js'); + _resetCpuProfilerForTest(); + initCpuProfiler(); + expect(isCpuProfileRecording()).toBe(false); + }); + }); + + describe('SIGUSR1 toggle (via start/stop cycle)', () => { + it('simulates signal toggle: start then stop', async () => { + // Simulate what handleSigusr1 does internally + expect(isCpuProfileRecording()).toBe(false); + + // First signal: start + const startResult = await startCpuProfile(); + expect(startResult.ok).toBe(true); + expect(isCpuProfileRecording()).toBe(true); + + // Second signal: stop + const stopResult = await stopCpuProfile({ outputDir: tmpDir }); + expect(stopResult.ok).toBe(true); + expect(isCpuProfileRecording()).toBe(false); + }); + + it('ignores stop when in idle state', async () => { + const result = await stopCpuProfile({ outputDir: tmpDir }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('not recording'); + } + }); + }); + + describe('empty profile guard', () => { + it('returns error when V8 returns empty profile', async () => { + _resetCpuProfilerForTest(); + const emptyMock = { + post: vi.fn().mockImplementation((method: string) => { + if (method === 'Profiler.stop') { + return Promise.resolve({ profile: undefined }); + } + return Promise.resolve(undefined); + }), + connect: vi.fn(), + disconnect: vi.fn(), + }; + _setSessionFactoryForTest(async () => emptyMock); + + await startCpuProfile(); + const result = await stopCpuProfile({ outputDir: tmpDir }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('empty profile'); + } + }); + }); +}); diff --git a/packages/cli/src/utils/cpuProfiler.ts b/packages/cli/src/utils/cpuProfiler.ts new file mode 100644 index 00000000000..7e9064c5fa4 --- /dev/null +++ b/packages/cli/src/utils/cpuProfiler.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * CPU profiling utility that generates .cpuprofile files for Chrome DevTools. + * + * Three trigger modes: + * 1. Environment variable: QWEN_CODE_CPU_PROFILE=1 — records from process start to exit + * 2. Signal toggle: SIGUSR1 — first signal starts, second stops and writes + * 3. Command: /doctor cpu-profile [--duration N] — records for N seconds + * + * Output: ~/.qwen/cpu-profiles/qwen-code-cpu--.cpuprofile + * Zero overhead when disabled (single env var check at init). + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { registerCleanup } from './cleanup.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ProfilerState = 'idle' | 'recording' | 'stopping'; + +export type CpuProfileStartResult = { ok: true } | { ok: false; error: string }; + +export type CpuProfileStopResult = + | { ok: true; filePath: string } + | { ok: false; error: string }; + +// Custom interface rather than importing from node:inspector/promises because +// the official Session.post() generic overload returns Promise, making +// dynamic method dispatch (Profiler.start/stop) cumbersome without per-call casts. +interface InspectorSession { + connect(): void; + disconnect(): void; + post(method: string, params?: Record): Promise; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MAX_PROFILES = 5; +const RATE_LIMIT_MS = 30_000; +const MIN_FREE_BYTES_AFTER_WRITE = 256 * 1024 * 1024; +const DEFAULT_SAMPLING_INTERVAL_US = 1000; // 1ms +const ESTIMATED_PROFILE_BYTES = 10 * 1024 * 1024; // 10 MiB conservative estimate + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +let state: ProfilerState = 'idle'; +let session: InspectorSession | null = null; +let initialized = false; +let signalHandlerRegistered = false; +const lastWriteByDir = new Map(); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Initialize CPU profiler. Call once at process start. + * Always registers SIGUSR1 handler (for ad-hoc profiling). + * When QWEN_CODE_CPU_PROFILE=1, also starts recording immediately. + */ +export function initCpuProfiler(): void { + if (initialized) return; + initialized = true; + + // Always register signal handler for ad-hoc profiling (non-Windows) + registerSignalHandler(); + + // Always register cleanup to flush any in-progress profile on exit + registerCleanup(async () => { + if (state === 'recording') { + const result = await stopCpuProfile(); + if (result.ok) { + process.stderr.write( + `[cpu-profiler] Profile written: ${result.filePath}\n`, + ); + } + } + }); + + const enabled = process.env['QWEN_CODE_CPU_PROFILE'] === '1'; + if (!enabled) return; + + // Start recording immediately in env-var mode + void startCpuProfile().then((result) => { + if (!result.ok) { + process.stderr.write(`[cpu-profiler] Failed to start: ${result.error}\n`); + } + }); +} + +/** + * Start CPU profiling. + * @param opts.samplingInterval - Sampling interval in microseconds (default 1000 = 1ms) + */ +export async function startCpuProfile(opts?: { + samplingInterval?: number; +}): Promise { + if (state !== 'idle') { + return { + ok: false, + error: + state === 'recording' + ? 'CPU profiling is already in progress.' + : 'CPU profiler is currently stopping. Please wait a moment and try again.', + }; + } + + // Set state eagerly before the first await to prevent concurrent callers + // (e.g., rapid SIGUSR1 signals) from both passing the idle guard. + state = 'recording'; + + try { + const inspectorSession = await getOrCreateSession(); + await inspectorSession.post('Profiler.enable'); + await inspectorSession.post('Profiler.setSamplingInterval', { + interval: opts?.samplingInterval ?? DEFAULT_SAMPLING_INTERVAL_US, + }); + await inspectorSession.post('Profiler.start'); + return { ok: true }; + } catch (error) { + state = 'idle'; + disconnectSession(); + return { ok: false, error: formatError(error) }; + } +} + +/** + * Stop CPU profiling and write the .cpuprofile file. + * @returns File path on success. + */ +export async function stopCpuProfile(options?: { + outputDir?: string; + now?: Date; + rateLimitMs?: number; + maxProfiles?: number; +}): Promise { + if (state !== 'recording') { + return { + ok: false, + error: + state === 'idle' + ? 'CPU profiler is not recording.' + : 'CPU profiler is already stopping.', + }; + } + + const outputDir = options?.outputDir ?? defaultOutputDir(); + const now = options?.now ?? new Date(); + const rateLimitMs = options?.rateLimitMs ?? RATE_LIMIT_MS; + const maxProfiles = options?.maxProfiles ?? MAX_PROFILES; + + // Check rate limit BEFORE writing to avoid excessive output. + // If rate-limited, tear down the V8 profiler (data is discarded) and reset + // state to 'idle' so the user can start a fresh recording later. + try { + enforceRateLimit(outputDir, now, rateLimitMs); + } catch (error) { + state = 'idle'; + if (session) { + session.post('Profiler.stop').catch(() => {}); + session.post('Profiler.disable').catch(() => {}); + } + disconnectSession(); + return { ok: false, error: formatError(error) }; + } + + state = 'stopping'; + + try { + if (!session) { + throw new Error( + 'Inspector session lost unexpectedly during Profiler.stop; the profile data could not be retrieved.', + ); + } + + const result = (await session.post('Profiler.stop')) as { + profile: unknown; + }; + if (!result.profile) { + throw new Error( + 'V8 Profiler.stop returned an empty profile; recording may have been interrupted.', + ); + } + await session.post('Profiler.disable'); + + fs.mkdirSync(outputDir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(outputDir, 0o700); + } catch { + // Best-effort hardening on filesystems without POSIX chmod. + } + + checkDiskSpace(outputDir); + + const filePath = path.join( + outputDir, + `qwen-code-cpu-${process.pid}-${formatTimestamp(now)}.cpuprofile`, + ); + + try { + fs.writeFileSync(filePath, JSON.stringify(result.profile), { + mode: 0o600, + }); + } catch (writeError) { + try { + fs.rmSync(filePath, { force: true }); + } catch { + // Best-effort cleanup of partial file. + } + throw writeError; + } + + recordWrite(outputDir, now); + cleanupOldProfiles(outputDir, maxProfiles); + + state = 'idle'; + return { ok: true, filePath }; + } catch (error) { + state = 'idle'; + disconnectSession(); + return { ok: false, error: formatError(error) }; + } +} + +/** + * Whether the profiler is currently recording. + */ +export function isCpuProfileRecording(): boolean { + return state === 'recording'; +} + +/** + * Register SIGUSR1 signal handler for toggle mode. + * Safe to call multiple times; only registers once. + * No-op on Windows (SIGUSR1 does not exist). + */ +export function registerSignalHandler(): void { + if (signalHandlerRegistered) return; + if (process.platform === 'win32') return; + + signalHandlerRegistered = true; + process.on('SIGUSR1', handleSigusr1); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Reset all module state. Test-only. */ +export function _resetCpuProfilerForTest(): void { + state = 'idle'; + initialized = false; + signalHandlerRegistered = false; + disconnectSession(); + lastWriteByDir.clear(); +} + +/** Clear rate limit state. Test-only. */ +export function clearCpuProfileRateLimit(): void { + lastWriteByDir.clear(); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function defaultOutputDir(): string { + return path.join(os.homedir(), '.qwen', 'cpu-profiles'); +} + +function formatTimestamp(now: Date): string { + return now.toISOString().replace(/[:.]/g, '-'); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// Overridable factory for testing (avoids mocking ESM dynamic imports) +let sessionFactory: (() => Promise) | null = null; + +/** Override session creation for testing. */ +export function _setSessionFactoryForTest( + factory: (() => Promise) | null, +): void { + sessionFactory = factory; +} + +async function getOrCreateSession(): Promise { + if (session) return session; + + if (sessionFactory) { + session = await sessionFactory(); + return session; + } + + // Dynamic import to avoid any overhead when profiling is disabled + const inspectorModule = await import('node:inspector/promises'); + const newSession = + new inspectorModule.Session() as unknown as InspectorSession; + newSession.connect(); + session = newSession; + return session; +} + +function disconnectSession(): void { + if (session) { + try { + session.disconnect(); + } catch { + // Ignore disconnect errors during cleanup. + } + session = null; + } +} + +function handleSigusr1(): void { + if (state === 'idle') { + void startCpuProfile().then((result) => { + if (result.ok) { + process.stderr.write( + `[cpu-profiler] Recording started (PID ${process.pid}). Send SIGUSR1 again to stop.\n`, + ); + } else { + process.stderr.write( + `[cpu-profiler] Failed to start: ${result.error}\n`, + ); + } + }); + } else if (state === 'recording') { + void stopCpuProfile().then((result) => { + if (result.ok) { + process.stderr.write( + `[cpu-profiler] Profile written: ${result.filePath}\n`, + ); + } else { + process.stderr.write( + `[cpu-profiler] Failed to stop: ${result.error}\n`, + ); + } + }); + } + // state === 'stopping': ignore, already in progress +} + +function enforceRateLimit( + outputDir: string, + now: Date, + rateLimitMs: number, +): void { + if (rateLimitMs <= 0) return; + + const key = path.resolve(outputDir); + const nowMs = now.getTime(); + const lastWriteMs = lastWriteByDir.get(key); + if (lastWriteMs !== undefined && nowMs - lastWriteMs < rateLimitMs) { + const waitSeconds = Math.ceil((rateLimitMs - (nowMs - lastWriteMs)) / 1000); + throw new Error( + `CPU profile rate limit: wait ${waitSeconds}s before writing another profile.`, + ); + } +} + +function recordWrite(outputDir: string, now: Date): void { + lastWriteByDir.set(path.resolve(outputDir), now.getTime()); +} + +function checkDiskSpace(outputDir: string): void { + try { + const stats = fs.statfsSync(outputDir); + const available = stats.bavail * stats.bsize; + if (available - ESTIMATED_PROFILE_BYTES < MIN_FREE_BYTES_AFTER_WRITE) { + throw new Error( + 'Insufficient free disk space for CPU profile; skipping to avoid filling the disk.', + ); + } + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Insufficient free disk') + ) { + throw error; + } + // statfsSync is not available on all platforms (e.g. Windows). + // Log a warning so it's not completely silent, but proceed anyway. + process.stderr.write( + '[cpu-profiler] Disk space check unavailable on this platform; skipping.\n', + ); + } +} + +function cleanupOldProfiles(outputDir: string, maxProfiles: number): void { + if (maxProfiles < 1) return; + + let profiles: string[]; + try { + profiles = fs + .readdirSync(outputDir) + .filter( + (name) => + name.startsWith('qwen-code-cpu-') && name.endsWith('.cpuprofile'), + ) + .map((name) => path.join(outputDir, name)) + .sort((a, b) => { + try { + return ( + fs.lstatSync(b).mtimeMs - fs.lstatSync(a).mtimeMs || + path.basename(b).localeCompare(path.basename(a)) + ); + } catch { + // Fall back to filename comparison if stat fails + return path.basename(b).localeCompare(path.basename(a)); + } + }); + } catch { + return; + } + + for (const filePath of profiles.slice(maxProfiles)) { + try { + fs.rmSync(filePath, { force: true }); + } catch { + // Cleanup is best effort. + } + } +}