From b4cfe76cbdb3c4153830bd22f227921dc44989c1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 12:12:34 +0800 Subject: [PATCH 01/12] feat(cli): add memory diagnostics doctor command --- .../memory-diagnostics-reference-design.md | 56 ++++ .../cli/src/ui/commands/doctorCommand.test.ts | 150 ++++++++++ packages/cli/src/ui/commands/doctorCommand.ts | 60 +++- packages/core/src/index.ts | 1 + .../core/src/utils/memoryDiagnostics.test.ts | 174 ++++++++++++ packages/core/src/utils/memoryDiagnostics.ts | 267 ++++++++++++++++++ 6 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 docs/plans/memory-diagnostics-reference-design.md create mode 100644 packages/core/src/utils/memoryDiagnostics.test.ts create mode 100644 packages/core/src/utils/memoryDiagnostics.ts diff --git a/docs/plans/memory-diagnostics-reference-design.md b/docs/plans/memory-diagnostics-reference-design.md new file mode 100644 index 00000000000..c4b754eae2a --- /dev/null +++ b/docs/plans/memory-diagnostics-reference-design.md @@ -0,0 +1,56 @@ +# Memory Diagnostics Reference Design + +## Context + +Issue #3000 tracks memory and performance diagnostics for long-running Qwen +Code sessions. The first PR should establish a small, low-risk diagnostic +surface before adding heavier profiling or retention changes. + +The design is reference-first: + +- Claude Code keeps memory diagnostics separate from heap snapshot generation. + Its diagnostics include process memory, V8 heap statistics, heap spaces, + resource usage, active handles/requests, file descriptors, Linux + `smaps_rollup`, and leak hints. +- Codex focuses heavily on bounded retention and lazy loading for long-lived + process state. Those ideas should guide later PRs that address conversation, + command output, and history retention. + +## First PR Scope + +Add a `/doctor memory` diagnostic path that captures a single point-in-time +snapshot: + +- `process.memoryUsage()` +- V8 heap statistics and heap spaces +- `process.resourceUsage()` +- active handle/request counts +- open file descriptor count when `/proc/self/fd` is available +- Linux `smaps_rollup` when available +- basic risk hints for heap pressure, detached contexts, excessive handles, + excessive requests, high file descriptor count, and native memory pressure + +This command should be cheap enough to run in normal sessions and safe on +platforms where Linux-only probes are unavailable. + +## Non-Goals + +This PR intentionally does not: + +- write heap snapshots +- run continuous polling +- change prompt/history retention +- change tool output retention +- alter module loading behavior + +Those are follow-up PRs after the diagnostic baseline exists. + +## Follow-Up PRs + +1. Add explicit snapshot/export support for deeper local investigation. +2. Add bounded retention for large command/tool outputs, using Codex's capped + output retention as the main reference. +3. Audit lazy loading and module startup paths after measurements identify + hot spots. +4. Add repeatable memory/performance benchmark scenarios for long-running + sessions. diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 188645b00bc..10bf18c33fd 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -10,14 +10,27 @@ import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import * as doctorChecksModule from '../../utils/doctorChecks.js'; import * as memoryDiagnosticsModule from '../../utils/memoryDiagnostics.js'; +import { collectMemoryDiagnostics } from '@qwen-code/qwen-code-core'; import type { DoctorCheckResult } from '../types.js'; vi.mock('../../utils/doctorChecks.js'); vi.mock('../../utils/memoryDiagnostics.js'); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ + ...(await importOriginal()), + collectMemoryDiagnostics: vi.fn(), +})); describe('doctorCommand', () => { let mockContext: CommandContext; + const getMemoryCommand = () => { + const memoryCommand = doctorCommand.subCommands?.find( + (command) => command.name === 'memory', + ); + expect(memoryCommand).toBeDefined(); + return memoryCommand!; + }; + const mockChecks: DoctorCheckResult[] = [ { category: 'System', @@ -96,6 +109,39 @@ describe('doctorCommand', () => { vi.mocked(doctorChecksModule.runDoctorChecks).mockResolvedValue(mockChecks); mockMemoryDiagnostics(); + vi.mocked(collectMemoryDiagnostics).mockResolvedValue({ + timestamp: '2026-05-01T10:00:00.000Z', + uptimeSeconds: 60, + memoryUsage: { + heapUsed: 1_000, + heapTotal: 2_000, + rss: 3_000, + external: 100, + arrayBuffers: 50, + }, + v8HeapStats: { + heapSizeLimit: 4_000, + totalHeapSize: 2_000, + usedHeapSize: 1_000, + mallocedMemory: 10, + peakMallocedMemory: 20, + detachedContexts: 0, + nativeContexts: 1, + }, + resourceUsage: { + maxRSS: 4_000, + userCPUTime: 10, + systemCPUTime: 20, + }, + activeHandles: 2, + activeRequests: 0, + platform: 'darwin', + nodeVersion: 'v20.19.0', + analysis: { + risks: [], + recommendation: 'No obvious leak indicators.', + }, + }); }); afterEach(() => { @@ -610,4 +656,108 @@ describe('doctorCommand', () => { // setPendingItem(null) should still be called via finally expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null); }); + + it('should return memory diagnostics as JSON for /doctor memory --json', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, '--json'); + + expect(doctorChecksModule.runDoctorChecks).not.toHaveBeenCalled(); + expect(collectMemoryDiagnostics).toHaveBeenCalledTimes(1); + expect(result).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'info', + }), + ); + expect( + JSON.parse(result?.type === 'message' ? result.content : '{}'), + ).toMatchObject({ + memoryUsage: { + heapUsed: 1_000, + }, + analysis: { + risks: [], + }, + }); + }); + + it('should return a readable memory diagnostics summary for /doctor memory', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, ''); + + expect(result).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Memory Diagnostics'), + }), + ); + expect(result?.type === 'message' ? result.content : '').toContain( + 'heapUsed', + ); + }); + + it('should register memory as a real doctor subcommand', () => { + expect(doctorCommand.subCommands?.map((command) => command.name)).toContain( + 'memory', + ); + }); + + it('should keep memory diagnostics successful when risk indicators exist', async () => { + vi.mocked(collectMemoryDiagnostics).mockResolvedValue({ + timestamp: '2026-05-01T10:00:00.000Z', + uptimeSeconds: 60, + memoryUsage: { + heapUsed: 3_500, + heapTotal: 4_000, + rss: 8_000, + external: 100, + arrayBuffers: 50, + }, + v8HeapStats: { + heapSizeLimit: 4_000, + totalHeapSize: 4_000, + usedHeapSize: 3_500, + mallocedMemory: 10, + peakMallocedMemory: 20, + detachedContexts: 0, + nativeContexts: 1, + }, + resourceUsage: { + maxRSS: 8_000, + userCPUTime: 10, + systemCPUTime: 20, + }, + activeHandles: 2, + activeRequests: 0, + platform: 'darwin', + nodeVersion: 'v20.19.0', + analysis: { + risks: [{ type: 'heap-pressure', message: 'Heap pressure detected.' }], + recommendation: 'WARNING: 1 potential leak indicator(s) found.', + }, + }); + const result = await getMemoryCommand().action!(mockContext, '--json'); + + expect(result).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'info', + }), + ); + }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 9c89cac6e05..64db79e7e46 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand } from './types.js'; +import type { CommandContext, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import type { HistoryItemDoctor } from '../types.js'; import { runDoctorChecks } from '../../utils/doctorChecks.js'; @@ -17,6 +17,10 @@ import { writeMemoryHeapSnapshot, } from '../../utils/memoryDiagnostics.js'; import { t } from '../../i18n/index.js'; +import { + collectMemoryDiagnostics, + type MemoryDiagnostics, +} from '@qwen-code/qwen-code-core'; const MEMORY_SUBCOMMAND = 'memory'; const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND] as const; @@ -217,4 +221,58 @@ export const doctorCommand: SlashCommand = { } } }, + subCommands: [ + { + name: 'memory', + get description() { + return t('Show current process memory diagnostics'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: memoryDoctorAction, + }, + ], }; + +async function memoryDoctorAction(_context: CommandContext, args = '') { + const tokens = args.trim().split(/\s+/).filter(Boolean); + const diagnostics = await collectMemoryDiagnostics(); + return { + type: 'message' as const, + messageType: 'info' as const, + content: tokens.includes('--json') + ? JSON.stringify(diagnostics, null, 2) + : formatMemoryDiagnostics(diagnostics), + }; +} + +function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { + const risks = + diagnostics.analysis.risks.length > 0 + ? diagnostics.analysis.risks + .map((risk) => ` - ${risk.type}: ${risk.message}`) + .join('\n') + : ' none'; + + return [ + 'Memory Diagnostics', + `timestamp: ${diagnostics.timestamp}`, + `uptimeSeconds: ${diagnostics.uptimeSeconds.toFixed(1)}`, + `heapUsed: ${formatBytes(diagnostics.memoryUsage.heapUsed)}`, + `heapTotal: ${formatBytes(diagnostics.memoryUsage.heapTotal)}`, + `rss: ${formatBytes(diagnostics.memoryUsage.rss)}`, + `external: ${formatBytes(diagnostics.memoryUsage.external)}`, + `arrayBuffers: ${formatBytes(diagnostics.memoryUsage.arrayBuffers)}`, + `v8HeapLimit: ${formatBytes(diagnostics.v8HeapStats.heapSizeLimit)}`, + `activeHandles: ${diagnostics.activeHandles}`, + `activeRequests: ${diagnostics.activeRequests}`, + `openFileDescriptors: ${diagnostics.openFileDescriptors ?? 'unavailable'}`, + 'risks:', + risks, + `recommendation: ${diagnostics.analysis.recommendation}`, + ].join('\n'); +} + +function formatBytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ebd1d4d897..bae6ca877c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -287,6 +287,7 @@ export * from './utils/gitIgnoreParser.js'; export * from './utils/gitUtils.js'; export * from './utils/ignorePatterns.js'; export * from './utils/jsonl-utils.js'; +export * from './utils/memoryDiagnostics.js'; export * from './utils/memoryDiscovery.js'; export * from './utils/modelId.js'; export { ConditionalRulesRegistry } from './utils/rulesDiscovery.js'; diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts new file mode 100644 index 00000000000..4fdfdcf9498 --- /dev/null +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { collectMemoryDiagnostics } from './memoryDiagnostics.js'; + +describe('collectMemoryDiagnostics', () => { + it('captures memory, V8, resource, handle, fd, smaps, and risk data', async () => { + const diagnostics = await collectMemoryDiagnostics({ + now: () => new Date('2026-05-01T10:00:00.000Z'), + sessionId: 'session-123', + qwenVersion: '0.15.6', + memoryUsage: () => ({ + heapUsed: 1_600, + heapTotal: 2_000, + rss: 5_000, + external: 700, + arrayBuffers: 300, + }), + heapStatistics: () => ({ + heap_size_limit: 2_000, + total_heap_size: 2_000, + total_heap_size_executable: 0, + total_physical_size: 2_000, + used_heap_size: 1_600, + malloced_memory: 100, + peak_malloced_memory: 200, + does_zap_garbage: 0, + number_of_native_contexts: 2, + number_of_detached_contexts: 1, + total_available_size: 400, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 700, + }), + heapSpaceStatistics: () => [ + { + space_name: 'old_space', + space_size: 1_000, + space_used_size: 800, + space_available_size: 200, + physical_space_size: 1_000, + }, + ], + resourceUsage: () => ({ + userCPUTime: 10, + systemCPUTime: 20, + maxRSS: 6, + sharedMemorySize: 0, + unsharedDataSize: 0, + unsharedStackSize: 0, + minorPageFault: 0, + majorPageFault: 0, + swappedOut: 0, + fsRead: 0, + fsWrite: 0, + ipcSent: 0, + ipcReceived: 0, + signalsCount: 0, + voluntaryContextSwitches: 0, + involuntaryContextSwitches: 0, + }), + uptimeSeconds: () => 60, + activeHandles: () => 101, + activeRequests: () => 3, + openFileDescriptors: async () => 501, + smapsRollup: async () => 'Rss: 5000 kB', + platform: 'linux', + nodeVersion: 'v20.19.0', + }); + + expect(diagnostics).toMatchObject({ + timestamp: '2026-05-01T10:00:00.000Z', + sessionId: 'session-123', + qwenVersion: '0.15.6', + uptimeSeconds: 60, + memoryUsage: { + heapUsed: 1_600, + heapTotal: 2_000, + rss: 5_000, + external: 700, + arrayBuffers: 300, + }, + v8HeapStats: { + heapSizeLimit: 2_000, + totalHeapSize: 2_000, + usedHeapSize: 1_600, + mallocedMemory: 100, + peakMallocedMemory: 200, + detachedContexts: 1, + nativeContexts: 2, + }, + v8HeapSpaces: [ + { + name: 'old_space', + size: 1_000, + used: 800, + available: 200, + }, + ], + resourceUsage: { + maxRSS: 6 * 1024, + userCPUTime: 10, + systemCPUTime: 20, + }, + activeHandles: 101, + activeRequests: 3, + openFileDescriptors: 501, + smapsRollup: 'Rss: 5000 kB', + platform: 'linux', + nodeVersion: 'v20.19.0', + }); + + expect('memoryGrowthRate' in diagnostics).toBe(false); + + expect(diagnostics.analysis.risks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'heap-pressure' }), + expect.objectContaining({ type: 'detached-contexts' }), + expect.objectContaining({ type: 'active-handles' }), + expect.objectContaining({ type: 'fd-leak' }), + expect.objectContaining({ type: 'native-memory-pressure' }), + ]), + ); + }); + + it('treats unsupported optional probes as unavailable instead of failing', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + heapSpaceStatistics: () => { + throw new Error('not available'); + }, + openFileDescriptors: async () => { + throw new Error('not available'); + }, + smapsRollup: async () => { + throw new Error('not available'); + }, + }); + + expect(diagnostics.v8HeapSpaces).toBeUndefined(); + expect(diagnostics.openFileDescriptors).toBeUndefined(); + expect(diagnostics.smapsRollup).toBeUndefined(); + expect(diagnostics.analysis.risks).toEqual([]); + expect(diagnostics.analysis.recommendation).toContain( + 'No obvious leak indicators', + ); + }); +}); diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts new file mode 100644 index 00000000000..8693ae7de40 --- /dev/null +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readdir, readFile } from 'node:fs/promises'; +import process from 'node:process'; +import v8 from 'node:v8'; + +export interface MemoryDiagnostics { + timestamp: string; + sessionId?: string; + qwenVersion?: string; + uptimeSeconds: number; + memoryUsage: NodeJS.MemoryUsage; + v8HeapStats: V8HeapStats; + v8HeapSpaces?: V8HeapSpaceStats[]; + resourceUsage: MemoryResourceUsage; + activeHandles: number; + activeRequests: number; + openFileDescriptors?: number; + smapsRollup?: string; + platform: NodeJS.Platform; + nodeVersion: string; + analysis: MemoryDiagnosticsAnalysis; +} + +export interface V8HeapStats { + heapSizeLimit: number; + totalHeapSize: number; + usedHeapSize: number; + mallocedMemory: number; + peakMallocedMemory: number; + detachedContexts: number; + nativeContexts: number; +} + +export interface V8HeapSpaceStats { + name: string; + size: number; + used: number; + available: number; +} + +export interface MemoryResourceUsage { + maxRSS: number; + userCPUTime: number; + systemCPUTime: number; +} + +export interface MemoryDiagnosticsAnalysis { + risks: MemoryRisk[]; + recommendation: string; +} + +export interface MemoryRisk { + type: + | 'heap-pressure' + | 'detached-contexts' + | 'active-handles' + | 'active-requests' + | 'fd-leak' + | 'native-memory-pressure'; + message: string; +} + +export interface MemoryDiagnosticsOptions { + now?: () => Date; + sessionId?: string; + qwenVersion?: string; + memoryUsage?: () => NodeJS.MemoryUsage; + heapStatistics?: () => v8.HeapInfo; + heapSpaceStatistics?: () => v8.HeapSpaceInfo[]; + resourceUsage?: () => NodeJS.ResourceUsage; + uptimeSeconds?: () => number; + activeHandles?: () => number; + activeRequests?: () => number; + openFileDescriptors?: () => Promise; + smapsRollup?: () => Promise; + platform?: NodeJS.Platform; + nodeVersion?: string; +} + +interface ProcessInternals { + _getActiveHandles?: () => unknown[]; + _getActiveRequests?: () => unknown[]; +} + +export async function collectMemoryDiagnostics( + options: MemoryDiagnosticsOptions = {}, +): Promise { + const now = options.now ?? (() => new Date()); + const memoryUsage = options.memoryUsage?.() ?? process.memoryUsage(); + const heapStatistics = options.heapStatistics?.() ?? v8.getHeapStatistics(); + const resourceUsage = options.resourceUsage?.() ?? process.resourceUsage(); + const uptimeSeconds = options.uptimeSeconds?.() ?? process.uptime(); + const openFileDescriptors = await optionalProbe( + options.openFileDescriptors ?? countOpenFileDescriptors, + ); + const smapsRollup = await optionalProbe( + options.smapsRollup ?? readProcSmapsRollup, + ); + const v8HeapSpaces = mapHeapSpaces( + await optionalSyncProbe( + options.heapSpaceStatistics ?? (() => v8.getHeapSpaceStatistics()), + ), + ); + + const diagnostics: MemoryDiagnostics = { + timestamp: now().toISOString(), + sessionId: options.sessionId, + qwenVersion: options.qwenVersion, + uptimeSeconds, + memoryUsage, + v8HeapStats: mapHeapStats(heapStatistics), + v8HeapSpaces, + resourceUsage: { + maxRSS: resourceUsage.maxRSS * 1024, + userCPUTime: resourceUsage.userCPUTime, + systemCPUTime: resourceUsage.systemCPUTime, + }, + activeHandles: getActiveHandlesCount(options.activeHandles), + activeRequests: getActiveRequestsCount(options.activeRequests), + openFileDescriptors, + smapsRollup, + platform: options.platform ?? process.platform, + nodeVersion: options.nodeVersion ?? process.version, + analysis: { + risks: [], + recommendation: '', + }, + }; + + diagnostics.analysis = analyzeMemoryDiagnostics(diagnostics); + return diagnostics; +} + +function mapHeapStats(heapInfo: v8.HeapInfo): V8HeapStats { + return { + heapSizeLimit: heapInfo.heap_size_limit, + totalHeapSize: heapInfo.total_heap_size, + usedHeapSize: heapInfo.used_heap_size, + mallocedMemory: heapInfo.malloced_memory, + peakMallocedMemory: heapInfo.peak_malloced_memory, + detachedContexts: heapInfo.number_of_detached_contexts, + nativeContexts: heapInfo.number_of_native_contexts, + }; +} + +function mapHeapSpaces( + heapSpaces: v8.HeapSpaceInfo[] | undefined, +): V8HeapSpaceStats[] | undefined { + return heapSpaces?.map((space) => ({ + name: space.space_name, + size: space.space_size, + used: space.space_used_size, + available: space.space_available_size, + })); +} + +function getActiveHandlesCount(probe?: () => number): number { + if (probe) { + return probe(); + } + const internals = process as unknown as ProcessInternals; + return internals._getActiveHandles?.().length ?? 0; +} + +function getActiveRequestsCount(probe?: () => number): number { + if (probe) { + return probe(); + } + const internals = process as unknown as ProcessInternals; + return internals._getActiveRequests?.().length ?? 0; +} + +async function countOpenFileDescriptors(): Promise { + return (await readdir('/proc/self/fd')).length; +} + +async function readProcSmapsRollup(): Promise { + return readFile('/proc/self/smaps_rollup', 'utf8'); +} + +async function optionalProbe( + probe: () => Promise, +): Promise { + try { + return await probe(); + } catch { + return undefined; + } +} + +async function optionalSyncProbe(probe: () => T): Promise { + try { + return probe(); + } catch { + return undefined; + } +} + +function analyzeMemoryDiagnostics( + diagnostics: MemoryDiagnostics, +): MemoryDiagnosticsAnalysis { + const risks: MemoryRisk[] = []; + const heapRatio = + diagnostics.v8HeapStats.heapSizeLimit > 0 + ? diagnostics.memoryUsage.heapUsed / diagnostics.v8HeapStats.heapSizeLimit + : 0; + + if (heapRatio >= 0.75) { + risks.push({ + type: 'heap-pressure', + message: `Heap usage is ${(heapRatio * 100).toFixed(1)}% of the V8 limit.`, + }); + } + + if (diagnostics.v8HeapStats.detachedContexts > 0) { + risks.push({ + type: 'detached-contexts', + message: `${diagnostics.v8HeapStats.detachedContexts} detached V8 context(s) detected.`, + }); + } + + if (diagnostics.activeHandles > 100) { + risks.push({ + type: 'active-handles', + message: `${diagnostics.activeHandles} active handle(s) detected.`, + }); + } + + if (diagnostics.activeRequests > 100) { + risks.push({ + type: 'active-requests', + message: `${diagnostics.activeRequests} active request(s) detected.`, + }); + } + + if ( + diagnostics.openFileDescriptors !== undefined && + diagnostics.openFileDescriptors > 500 + ) { + risks.push({ + type: 'fd-leak', + message: `${diagnostics.openFileDescriptors} open file descriptor(s) detected.`, + }); + } + + const nativeMemory = + diagnostics.memoryUsage.rss - diagnostics.memoryUsage.heapUsed; + if (nativeMemory > diagnostics.memoryUsage.heapUsed * 2) { + risks.push({ + type: 'native-memory-pressure', + message: `Native memory is ${nativeMemory} bytes, larger than heapUsed.`, + }); + } + + return { + risks, + recommendation: + risks.length > 0 + ? `WARNING: ${risks.length} potential leak indicator(s) found.` + : 'No obvious leak indicators. Check heap snapshot for retained objects.', + }; +} From 6aa8bcb82b72bf0ab5018ddd7fb694c168197603 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:34:33 +0000 Subject: [PATCH 02/12] fix(core): platform-aware maxRSS conversion and accurate risk message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract platform detection before building diagnostics so the correct unit conversion can be applied: multiply by 1024 on Linux (where process.resourceUsage().maxRSS is in KB) but leave the value unchanged on macOS/Windows (where it is already in bytes). - Correct the native-memory-pressure risk message to accurately state that the threshold is 2× heap used, not just "larger than heapUsed". - Add a dedicated test to assert that maxRSS is not multiplied on a non-Linux platform (darwin). All 3 core and 9 CLI tests pass; typecheck clean. Agent-Logs-Url: https://github.com/QwenLM/qwen-code/sessions/9b413337-68ed-4d5c-af99-0d42378900c3 --- .../core/src/utils/memoryDiagnostics.test.ts | 51 +++++++++++++++++++ packages/core/src/utils/memoryDiagnostics.ts | 12 +++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index 4fdfdcf9498..a74b25c1797 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -127,6 +127,57 @@ describe('collectMemoryDiagnostics', () => { ); }); + it('does not multiply maxRSS by 1024 on non-Linux platforms', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + resourceUsage: () => ({ + userCPUTime: 10, + systemCPUTime: 20, + maxRSS: 4_096, + sharedMemorySize: 0, + unsharedDataSize: 0, + unsharedStackSize: 0, + minorPageFault: 0, + majorPageFault: 0, + swappedOut: 0, + fsRead: 0, + fsWrite: 0, + ipcSent: 0, + ipcReceived: 0, + signalsCount: 0, + voluntaryContextSwitches: 0, + involuntaryContextSwitches: 0, + }), + platform: 'darwin', + nodeVersion: 'v20.19.0', + }); + + // On macOS, maxRSS is already in bytes — no ×1024 conversion. + expect(diagnostics.resourceUsage.maxRSS).toBe(4_096); + }); + it('treats unsupported optional probes as unavailable instead of failing', async () => { const diagnostics = await collectMemoryDiagnostics({ memoryUsage: () => ({ diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index 8693ae7de40..d9077ed7981 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -91,6 +91,7 @@ export async function collectMemoryDiagnostics( options: MemoryDiagnosticsOptions = {}, ): Promise { const now = options.now ?? (() => new Date()); + const platform = options.platform ?? process.platform; const memoryUsage = options.memoryUsage?.() ?? process.memoryUsage(); const heapStatistics = options.heapStatistics?.() ?? v8.getHeapStatistics(); const resourceUsage = options.resourceUsage?.() ?? process.resourceUsage(); @@ -107,6 +108,11 @@ export async function collectMemoryDiagnostics( ), ); + // process.resourceUsage().maxRSS is in kilobytes on Linux but bytes on + // macOS/Windows. Normalise to bytes for a consistent diagnostic unit. + const maxRSSBytes = + platform === 'linux' ? resourceUsage.maxRSS * 1024 : resourceUsage.maxRSS; + const diagnostics: MemoryDiagnostics = { timestamp: now().toISOString(), sessionId: options.sessionId, @@ -116,7 +122,7 @@ export async function collectMemoryDiagnostics( v8HeapStats: mapHeapStats(heapStatistics), v8HeapSpaces, resourceUsage: { - maxRSS: resourceUsage.maxRSS * 1024, + maxRSS: maxRSSBytes, userCPUTime: resourceUsage.userCPUTime, systemCPUTime: resourceUsage.systemCPUTime, }, @@ -124,7 +130,7 @@ export async function collectMemoryDiagnostics( activeRequests: getActiveRequestsCount(options.activeRequests), openFileDescriptors, smapsRollup, - platform: options.platform ?? process.platform, + platform, nodeVersion: options.nodeVersion ?? process.version, analysis: { risks: [], @@ -253,7 +259,7 @@ function analyzeMemoryDiagnostics( if (nativeMemory > diagnostics.memoryUsage.heapUsed * 2) { risks.push({ type: 'native-memory-pressure', - message: `Native memory is ${nativeMemory} bytes, larger than heapUsed.`, + message: `Native memory (${nativeMemory} bytes) is more than 2× heap used (${diagnostics.memoryUsage.heapUsed} bytes).`, }); } From 2800630cd903c485d3fddb4e1980f7ee15095a63 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 20:04:18 +0800 Subject: [PATCH 03/12] test(core): cover active request memory risk --- .../core/src/utils/memoryDiagnostics.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index a74b25c1797..d274989cf2a 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -222,4 +222,39 @@ describe('collectMemoryDiagnostics', () => { 'No obvious leak indicators', ); }); + + it('flags unusually high active requests', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + activeRequests: () => 101, + }); + + expect(diagnostics.analysis.risks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'active-requests' }), + ]), + ); + }); }); From c6c0cead175c62baac4d6ea99e00b2f982ee13bc Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 2 May 2026 20:14:38 +0800 Subject: [PATCH 04/12] fix(cli): address memory diagnostics review feedback --- .../acp-integration/session/Session.test.ts | 34 +++++++++++++++ .../src/acp-integration/session/Session.ts | 10 +++-- .../cli/src/ui/commands/doctorCommand.test.ts | 11 +++++ packages/cli/src/ui/commands/doctorCommand.ts | 17 ++++---- packages/cli/src/ui/commands/types.ts | 6 +++ .../core/src/utils/memoryDiagnostics.test.ts | 42 +++++++++++++++++-- packages/core/src/utils/memoryDiagnostics.ts | 5 +-- 7 files changed, 104 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index e11f4ac4357..a58d0844fe3 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -560,6 +560,40 @@ describe('Session', () => { }); }); + it('honors explicit no-input override for built-in commands with subCommands', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'doctor', + description: 'Run installation and environment diagnostics', + kind: 'built-in', + acceptsInput: false, + subCommands: [ + { + name: 'memory', + description: 'Show current process memory diagnostics', + kind: 'built-in', + }, + ], + }, + ]); + + await session.sendAvailableCommandsUpdate(); + + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'doctor', + description: 'Run installation and environment diagnostics', + input: null, + }, + ], + }, + }); + }); + it('attaches available skills to available_commands_update metadata', async () => { getAvailableCommandsSpy.mockResolvedValueOnce([ { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index de68a101366..41d4401d159 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1379,16 +1379,18 @@ export class Session implements SessionContext { // support get input: null so the client auto-submits them on selection. // // A command is considered to accept arguments when any of: + // - it explicitly overrides acceptsInput // - it is not a BUILT_IN command (skills, file commands, etc.) // - it has a completion function // - it declares an argumentHint // - it has subCommands const availableCommands: AvailableCommand[] = slashCommands.map((cmd) => { const acceptsInput = - cmd.kind !== CommandKind.BUILT_IN || - cmd.completion != null || - cmd.argumentHint != null || - (cmd.subCommands != null && cmd.subCommands.length > 0); + cmd.acceptsInput ?? + (cmd.kind !== CommandKind.BUILT_IN || + cmd.completion != null || + cmd.argumentHint != null || + (cmd.subCommands != null && cmd.subCommands.length > 0)); return { name: cmd.name, description: cmd.description, diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 10bf18c33fd..bc38c6e1b19 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -711,6 +711,17 @@ describe('doctorCommand', () => { ); }); + it('should render small memory values without rounding to zero MiB', async () => { + const result = await getMemoryCommand().action!(mockContext, ''); + + expect(result?.type === 'message' ? result.content : '').toContain( + 'heapUsed: 1.0 KB', + ); + expect(result?.type === 'message' ? result.content : '').not.toContain( + '0.00 MiB', + ); + }); + it('should register memory as a real doctor subcommand', () => { expect(doctorCommand.subCommands?.map((command) => command.name)).toContain( 'memory', diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 64db79e7e46..871bce2868a 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -21,6 +21,7 @@ import { collectMemoryDiagnostics, type MemoryDiagnostics, } from '@qwen-code/qwen-code-core'; +import { formatMemoryUsage } from '../utils/formatters.js'; const MEMORY_SUBCOMMAND = 'memory'; const DOCTOR_SUBCOMMANDS = [MEMORY_SUBCOMMAND] as const; @@ -258,12 +259,12 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { 'Memory Diagnostics', `timestamp: ${diagnostics.timestamp}`, `uptimeSeconds: ${diagnostics.uptimeSeconds.toFixed(1)}`, - `heapUsed: ${formatBytes(diagnostics.memoryUsage.heapUsed)}`, - `heapTotal: ${formatBytes(diagnostics.memoryUsage.heapTotal)}`, - `rss: ${formatBytes(diagnostics.memoryUsage.rss)}`, - `external: ${formatBytes(diagnostics.memoryUsage.external)}`, - `arrayBuffers: ${formatBytes(diagnostics.memoryUsage.arrayBuffers)}`, - `v8HeapLimit: ${formatBytes(diagnostics.v8HeapStats.heapSizeLimit)}`, + `heapUsed: ${formatMemoryUsage(diagnostics.memoryUsage.heapUsed)}`, + `heapTotal: ${formatMemoryUsage(diagnostics.memoryUsage.heapTotal)}`, + `rss: ${formatMemoryUsage(diagnostics.memoryUsage.rss)}`, + `external: ${formatMemoryUsage(diagnostics.memoryUsage.external)}`, + `arrayBuffers: ${formatMemoryUsage(diagnostics.memoryUsage.arrayBuffers)}`, + `v8HeapLimit: ${formatMemoryUsage(diagnostics.v8HeapStats.heapSizeLimit)}`, `activeHandles: ${diagnostics.activeHandles}`, `activeRequests: ${diagnostics.activeRequests}`, `openFileDescriptors: ${diagnostics.openFileDescriptors ?? 'unavailable'}`, @@ -272,7 +273,3 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { `recommendation: ${diagnostics.analysis.recommendation}`, ].join('\n'); } - -function formatBytes(bytes: number): string { - return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; -} diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index ee2c1836efd..26b9054725c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -362,6 +362,12 @@ export interface SlashCommand { */ argumentHint?: string; + /** + * Whether command-picker clients should wait for additional user input before + * submitting this command. Defaults are inferred from command metadata. + */ + acceptsInput?: boolean; + /** * Describes when to use this command — injected into the model-visible * description for modelInvocable commands. diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index d274989cf2a..12b85ed35b4 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -26,8 +26,8 @@ describe('collectMemoryDiagnostics', () => { total_heap_size_executable: 0, total_physical_size: 2_000, used_heap_size: 1_600, - malloced_memory: 100, - peak_malloced_memory: 200, + malloced_memory: 4_000, + peak_malloced_memory: 4_500, does_zap_garbage: 0, number_of_native_contexts: 2, number_of_detached_contexts: 1, @@ -88,8 +88,8 @@ describe('collectMemoryDiagnostics', () => { heapSizeLimit: 2_000, totalHeapSize: 2_000, usedHeapSize: 1_600, - mallocedMemory: 100, - peakMallocedMemory: 200, + mallocedMemory: 4_000, + peakMallocedMemory: 4_500, detachedContexts: 1, nativeContexts: 2, }, @@ -257,4 +257,38 @@ describe('collectMemoryDiagnostics', () => { ]), ); }); + + it('does not flag native pressure from normal RSS overhead alone', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 5 * 1024 * 1024, + heapTotal: 8 * 1024 * 1024, + rss: 50 * 1024 * 1024, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 512 * 1024 * 1024, + total_heap_size: 8 * 1024 * 1024, + total_heap_size_executable: 0, + total_physical_size: 8 * 1024 * 1024, + used_heap_size: 5 * 1024 * 1024, + malloced_memory: 512 * 1024, + peak_malloced_memory: 1024 * 1024, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 500 * 1024 * 1024, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + }); + + expect(diagnostics.analysis.risks).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'native-memory-pressure' }), + ]), + ); + }); }); diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index d9077ed7981..2220778d0be 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -254,12 +254,11 @@ function analyzeMemoryDiagnostics( }); } - const nativeMemory = - diagnostics.memoryUsage.rss - diagnostics.memoryUsage.heapUsed; + const nativeMemory = diagnostics.v8HeapStats.mallocedMemory; if (nativeMemory > diagnostics.memoryUsage.heapUsed * 2) { risks.push({ type: 'native-memory-pressure', - message: `Native memory (${nativeMemory} bytes) is more than 2× heap used (${diagnostics.memoryUsage.heapUsed} bytes).`, + message: `Native malloced memory (${nativeMemory} bytes) is more than 2× heap used (${diagnostics.memoryUsage.heapUsed} bytes).`, }); } From 9bca6191a43f285c3b3eec43950d0d85fe908dae Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 6 May 2026 14:09:12 +0800 Subject: [PATCH 05/12] fix(cli): harden memory diagnostics review fixes --- .../acp-integration/session/Session.test.ts | 27 +++ .../src/acp-integration/session/Session.ts | 14 +- .../cli/src/ui/commands/doctorCommand.test.ts | 50 +++++- packages/cli/src/ui/commands/doctorCommand.ts | 46 ++++- .../core/src/utils/memoryDiagnostics.test.ts | 170 +++++++++++++++++- packages/core/src/utils/memoryDiagnostics.ts | 92 +++++++--- 6 files changed, 355 insertions(+), 44 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index a58d0844fe3..aba61cbd8f2 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -594,6 +594,33 @@ describe('Session', () => { }); }); + it('honors explicit input override for built-in commands without input metadata', async () => { + getAvailableCommandsSpy.mockResolvedValueOnce([ + { + name: 'diagnostics', + description: 'Run diagnostics', + kind: 'built-in', + acceptsInput: true, + }, + ]); + + await session.sendAvailableCommandsUpdate(); + + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'diagnostics', + description: 'Run diagnostics', + input: { hint: '' }, + }, + ], + }, + }); + }); + it('attaches available skills to available_commands_update metadata', async () => { getAvailableCommandsSpy.mockResolvedValueOnce([ { diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 41d4401d159..a8ae346c366 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1378,12 +1378,14 @@ export class Session implements SessionContext { // let users type arguments before submitting. Commands with no argument // support get input: null so the client auto-submits them on selection. // - // A command is considered to accept arguments when any of: - // - it explicitly overrides acceptsInput - // - it is not a BUILT_IN command (skills, file commands, etc.) - // - it has a completion function - // - it declares an argumentHint - // - it has subCommands + // acceptsInput is determined by: + // 1. cmd.acceptsInput, if explicitly set (true or false overrides + // inference) + // 2. Otherwise, a command accepts arguments when any of: + // - it is not a BUILT_IN command (skills, file commands, etc.) + // - it has a completion function + // - it declares an argumentHint + // - it has subCommands const availableCommands: AvailableCommand[] = slashCommands.map((cmd) => { const acceptsInput = cmd.acceptsInput ?? diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index bc38c6e1b19..b4623e8091a 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -123,8 +123,8 @@ describe('doctorCommand', () => { heapSizeLimit: 4_000, totalHeapSize: 2_000, usedHeapSize: 1_000, - mallocedMemory: 10, - peakMallocedMemory: 20, + mallocedMemory: 2_048, + peakMallocedMemory: 4_096, detachedContexts: 0, nativeContexts: 1, }, @@ -153,6 +153,7 @@ describe('doctorCommand', () => { expect(doctorCommand.description).toBe( 'Run installation and environment diagnostics', ); + expect(doctorCommand.acceptsInput).toBe(false); }); it('should complete memory subcommand names', async () => { @@ -709,6 +710,9 @@ describe('doctorCommand', () => { expect(result?.type === 'message' ? result.content : '').toContain( 'heapUsed', ); + expect(result?.type === 'message' ? result.content : '').toContain( + 'v8MallocedMemory: 2.0 KB', + ); }); it('should render small memory values without rounding to zero MiB', async () => { @@ -726,9 +730,10 @@ describe('doctorCommand', () => { expect(doctorCommand.subCommands?.map((command) => command.name)).toContain( 'memory', ); + expect(getMemoryCommand().argumentHint).toBe('[--json]'); }); - it('should keep memory diagnostics successful when risk indicators exist', async () => { + it('should render risk indicators without failing memory diagnostics', async () => { vi.mocked(collectMemoryDiagnostics).mockResolvedValue({ timestamp: '2026-05-01T10:00:00.000Z', uptimeSeconds: 60, @@ -762,7 +767,7 @@ describe('doctorCommand', () => { recommendation: 'WARNING: 1 potential leak indicator(s) found.', }, }); - const result = await getMemoryCommand().action!(mockContext, '--json'); + const result = await getMemoryCommand().action!(mockContext, ''); expect(result).toEqual( expect.objectContaining({ @@ -770,5 +775,42 @@ describe('doctorCommand', () => { messageType: 'info', }), ); + expect(result?.type === 'message' ? result.content : '').toContain( + 'heap-pressure: Heap pressure detected.', + ); + }); + + it('should skip memory diagnostics when already aborted', async () => { + const abortController = new AbortController(); + abortController.abort(); + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + abortSignal: abortController.signal, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, ''); + + expect(result).toBeUndefined(); + expect(collectMemoryDiagnostics).not.toHaveBeenCalled(); + }); + + it('should return an error message when memory diagnostics fail', async () => { + vi.mocked(collectMemoryDiagnostics).mockRejectedValueOnce( + new Error('probe failed'), + ); + + const result = await getMemoryCommand().action!(mockContext, ''); + + expect(result).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('probe failed'), + }), + ); }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 871bce2868a..a16d4a8069a 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -230,21 +230,43 @@ export const doctorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + argumentHint: '[--json]', action: memoryDoctorAction, }, ], }; -async function memoryDoctorAction(_context: CommandContext, args = '') { +async function memoryDoctorAction(context: CommandContext, args = '') { + if (context.abortSignal?.aborted) { + return; + } + const tokens = args.trim().split(/\s+/).filter(Boolean); - const diagnostics = await collectMemoryDiagnostics(); - return { - type: 'message' as const, - messageType: 'info' as const, - content: tokens.includes('--json') - ? JSON.stringify(diagnostics, null, 2) - : formatMemoryDiagnostics(diagnostics), - }; + try { + const diagnostics = await collectMemoryDiagnostics(); + + if (context.abortSignal?.aborted) { + return; + } + + return { + type: 'message' as const, + messageType: 'info' as const, + content: tokens.includes('--json') + ? JSON.stringify(diagnostics, null, 2) + : formatMemoryDiagnostics(diagnostics), + }; + } catch (error) { + if (context.abortSignal?.aborted) { + return; + } + + return { + type: 'message' as const, + messageType: 'error' as const, + content: `Failed to collect memory diagnostics: ${formatError(error)}`, + }; + } } function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { @@ -265,6 +287,8 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { `external: ${formatMemoryUsage(diagnostics.memoryUsage.external)}`, `arrayBuffers: ${formatMemoryUsage(diagnostics.memoryUsage.arrayBuffers)}`, `v8HeapLimit: ${formatMemoryUsage(diagnostics.v8HeapStats.heapSizeLimit)}`, + `v8MallocedMemory: ${formatMemoryUsage(diagnostics.v8HeapStats.mallocedMemory)}`, + `v8PeakMallocedMemory: ${formatMemoryUsage(diagnostics.v8HeapStats.peakMallocedMemory)}`, `activeHandles: ${diagnostics.activeHandles}`, `activeRequests: ${diagnostics.activeRequests}`, `openFileDescriptors: ${diagnostics.openFileDescriptors ?? 'unavailable'}`, @@ -273,3 +297,7 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { `recommendation: ${diagnostics.analysis.recommendation}`, ].join('\n'); } + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index 12b85ed35b4..2f4ba0e2a4f 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -4,10 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const debugLogger = vi.hoisted(() => ({ + debug: vi.fn(), +})); + +vi.mock('./debugLogger.js', () => ({ + createDebugLogger: () => debugLogger, +})); + import { collectMemoryDiagnostics } from './memoryDiagnostics.js'; describe('collectMemoryDiagnostics', () => { + afterEach(() => { + debugLogger.debug.mockReset(); + vi.restoreAllMocks(); + }); + it('captures memory, V8, resource, handle, fd, smaps, and risk data', async () => { const diagnostics = await collectMemoryDiagnostics({ now: () => new Date('2026-05-01T10:00:00.000Z'), @@ -125,6 +139,13 @@ describe('collectMemoryDiagnostics', () => { expect.objectContaining({ type: 'native-memory-pressure' }), ]), ); + + const nativeRisk = diagnostics.analysis.risks.find( + (risk) => risk.type === 'native-memory-pressure', + ); + expect(nativeRisk?.message).toContain('3.9 KB'); + expect(nativeRisk?.message).toContain('1.6 KB'); + expect(nativeRisk?.message).not.toContain('4000 bytes'); }); it('does not multiply maxRSS by 1024 on non-Linux platforms', async () => { @@ -206,6 +227,8 @@ describe('collectMemoryDiagnostics', () => { heapSpaceStatistics: () => { throw new Error('not available'); }, + activeHandles: () => 0, + activeRequests: () => 0, openFileDescriptors: async () => { throw new Error('not available'); }, @@ -221,6 +244,112 @@ describe('collectMemoryDiagnostics', () => { expect(diagnostics.analysis.recommendation).toContain( 'No obvious leak indicators', ); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('heapSpaceStatistics'), + expect.any(Error), + ); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('openFileDescriptors'), + expect.any(Error), + ); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('smapsRollup'), + expect.any(Error), + ); + }); + + it('treats active handle and request probe failures as zero counts', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + activeHandles: () => { + throw new Error('handles unavailable'); + }, + activeRequests: () => { + throw new Error('requests unavailable'); + }, + }); + + expect(diagnostics.activeHandles).toBe(0); + expect(diagnostics.activeRequests).toBe(0); + expect(diagnostics.analysis.risks).toEqual([]); + }); + + it('starts independent optional probes before awaiting slow probes', async () => { + let resolveFileDescriptors: ((count: number) => void) | undefined; + const fileDescriptors = new Promise((resolve) => { + resolveFileDescriptors = resolve; + }); + let smapsStarted = false; + let heapSpacesStarted = false; + + const diagnosticsPromise = collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + heapSpaceStatistics: () => { + heapSpacesStarted = true; + return []; + }, + activeHandles: () => 0, + activeRequests: () => 0, + openFileDescriptors: () => fileDescriptors, + smapsRollup: async () => { + smapsStarted = true; + return 'Rss: 300 kB'; + }, + }); + + await Promise.resolve(); + expect(smapsStarted).toBe(true); + expect(heapSpacesStarted).toBe(true); + + resolveFileDescriptors?.(4); + const diagnostics = await diagnosticsPromise; + + expect(diagnostics.openFileDescriptors).toBe(4); + expect(diagnostics.smapsRollup).toBe('Rss: 300 kB'); }); it('flags unusually high active requests', async () => { @@ -291,4 +420,43 @@ describe('collectMemoryDiagnostics', () => { ]), ); }); + + it('flags RSS that is much larger than JS heap with a high floor', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 50 * 1024 * 1024, + heapTotal: 64 * 1024 * 1024, + rss: 800 * 1024 * 1024, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 512 * 1024 * 1024, + total_heap_size: 64 * 1024 * 1024, + total_heap_size_executable: 0, + total_physical_size: 64 * 1024 * 1024, + used_heap_size: 50 * 1024 * 1024, + malloced_memory: 512 * 1024, + peak_malloced_memory: 1024 * 1024, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 450 * 1024 * 1024, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + activeHandles: () => 0, + activeRequests: () => 0, + }); + + expect(diagnostics.analysis.risks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'rss-heap-gap', + message: expect.stringContaining('800.0 MB'), + }), + ]), + ); + }); }); diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index 2220778d0be..e212eb2deb0 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -7,6 +7,12 @@ import { readdir, readFile } from 'node:fs/promises'; import process from 'node:process'; import v8 from 'node:v8'; +import { createDebugLogger } from './debugLogger.js'; +import { formatMemoryUsage } from './formatters.js'; + +const RSS_HEAP_GAP_RATIO = 10; +const RSS_HEAP_GAP_MIN_BYTES = 256 * 1024 * 1024; +const debugLogger = createDebugLogger('MEMORY_DIAGNOSTICS'); export interface MemoryDiagnostics { timestamp: string; @@ -61,7 +67,8 @@ export interface MemoryRisk { | 'active-handles' | 'active-requests' | 'fd-leak' - | 'native-memory-pressure'; + | 'native-memory-pressure' + | 'rss-heap-gap'; message: string; } @@ -96,17 +103,19 @@ export async function collectMemoryDiagnostics( const heapStatistics = options.heapStatistics?.() ?? v8.getHeapStatistics(); const resourceUsage = options.resourceUsage?.() ?? process.resourceUsage(); const uptimeSeconds = options.uptimeSeconds?.() ?? process.uptime(); - const openFileDescriptors = await optionalProbe( - options.openFileDescriptors ?? countOpenFileDescriptors, - ); - const smapsRollup = await optionalProbe( - options.smapsRollup ?? readProcSmapsRollup, - ); - const v8HeapSpaces = mapHeapSpaces( - await optionalSyncProbe( - options.heapSpaceStatistics ?? (() => v8.getHeapSpaceStatistics()), - ), - ); + const [openFileDescriptors, smapsRollup, heapSpaceStatistics] = + await Promise.all([ + optionalProbe( + 'openFileDescriptors', + options.openFileDescriptors ?? countOpenFileDescriptors, + ), + optionalProbe('smapsRollup', options.smapsRollup ?? readProcSmapsRollup), + optionalSyncProbe( + 'heapSpaceStatistics', + options.heapSpaceStatistics ?? (() => v8.getHeapSpaceStatistics()), + ), + ]); + const v8HeapSpaces = mapHeapSpaces(heapSpaceStatistics); // process.resourceUsage().maxRSS is in kilobytes on Linux but bytes on // macOS/Windows. Normalise to bytes for a consistent diagnostic unit. @@ -166,19 +175,29 @@ function mapHeapSpaces( } function getActiveHandlesCount(probe?: () => number): number { - if (probe) { - return probe(); + try { + if (probe) { + return probe(); + } + const internals = process as unknown as ProcessInternals; + return internals._getActiveHandles?.().length ?? 0; + } catch (error) { + logProbeFailure('activeHandles', error); + return 0; } - const internals = process as unknown as ProcessInternals; - return internals._getActiveHandles?.().length ?? 0; } function getActiveRequestsCount(probe?: () => number): number { - if (probe) { - return probe(); + try { + if (probe) { + return probe(); + } + const internals = process as unknown as ProcessInternals; + return internals._getActiveRequests?.().length ?? 0; + } catch (error) { + logProbeFailure('activeRequests', error); + return 0; } - const internals = process as unknown as ProcessInternals; - return internals._getActiveRequests?.().length ?? 0; } async function countOpenFileDescriptors(): Promise { @@ -190,23 +209,33 @@ async function readProcSmapsRollup(): Promise { } async function optionalProbe( + name: string, probe: () => Promise, ): Promise { try { return await probe(); - } catch { + } catch (error) { + logProbeFailure(name, error); return undefined; } } -async function optionalSyncProbe(probe: () => T): Promise { +async function optionalSyncProbe( + name: string, + probe: () => T, +): Promise { try { return probe(); - } catch { + } catch (error) { + logProbeFailure(name, error); return undefined; } } +function logProbeFailure(name: string, error: unknown): void { + debugLogger.debug(`memory diagnostics probe failed: ${name}`, error); +} + function analyzeMemoryDiagnostics( diagnostics: MemoryDiagnostics, ): MemoryDiagnosticsAnalysis { @@ -254,11 +283,26 @@ function analyzeMemoryDiagnostics( }); } + // Use mallocedMemory instead of rss - heapUsed. RSS includes normal process + // overhead such as code segments, shared libraries, stacks, and mapped files, + // which creates false positives on healthy Node.js processes. const nativeMemory = diagnostics.v8HeapStats.mallocedMemory; if (nativeMemory > diagnostics.memoryUsage.heapUsed * 2) { risks.push({ type: 'native-memory-pressure', - message: `Native malloced memory (${nativeMemory} bytes) is more than 2× heap used (${diagnostics.memoryUsage.heapUsed} bytes).`, + message: `V8 native malloced memory (${formatMemoryUsage(nativeMemory)}) is more than 2× heap used (${formatMemoryUsage(diagnostics.memoryUsage.heapUsed)}).`, + }); + } + + if ( + diagnostics.memoryUsage.heapUsed > 0 && + diagnostics.memoryUsage.rss >= RSS_HEAP_GAP_MIN_BYTES && + diagnostics.memoryUsage.rss > + diagnostics.memoryUsage.heapUsed * RSS_HEAP_GAP_RATIO + ) { + risks.push({ + type: 'rss-heap-gap', + message: `RSS (${formatMemoryUsage(diagnostics.memoryUsage.rss)}) is more than ${RSS_HEAP_GAP_RATIO}× heap used (${formatMemoryUsage(diagnostics.memoryUsage.heapUsed)}). Check native addons, libuv buffers, mapped files, or retained tool output.`, }); } From 2f086a5f0544a119cf654ad16d9c8c678c11a6ee Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 6 May 2026 14:29:57 +0800 Subject: [PATCH 06/12] fix(memory-diagnostics): tighten risk thresholds and expand readable output - Add 64MB absolute floor on native-memory-pressure so cold processes don't trip the 2x ratio check; raise active-handles threshold from 100 to 256 - Show detachedContexts, nativeContexts, maxRSS, CPU times, smapsRollup availability, and v8HeapSpaces summary in the readable /doctor memory output - Validate unknown memory subcommand args with a usage hint instead of silently dropping them - Wrap human-readable strings in t(...) for i18n parity with the rest of doctor - Advertise the memory subcommand via /doctor argumentHint while keeping acceptsInput false so the parent still auto-submits - Document _getActiveHandles/_getActiveRequests as undocumented Node internals - Update tests for new thresholds, expanded output, unknown-arg path, and abort-during-json --- .../cli/src/ui/commands/doctorCommand.test.ts | 78 ++++++++++++ packages/cli/src/ui/commands/doctorCommand.ts | 62 ++++++++-- .../core/src/utils/memoryDiagnostics.test.ts | 117 ++++++++++++++---- packages/core/src/utils/memoryDiagnostics.ts | 25 +++- 4 files changed, 246 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index b4623e8091a..7a021ac39f6 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -813,4 +813,82 @@ describe('doctorCommand', () => { }), ); }); + + it('should reject unknown arguments with a usage hint', async () => { + const result = await getMemoryCommand().action!(mockContext, '--bogus'); + + expect(collectMemoryDiagnostics).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('--bogus'), + }), + ); + expect(result?.type === 'message' ? result.content : '').toContain( + '/doctor memory [--json]', + ); + }); + + it('should suppress JSON output when aborted between probe and return', async () => { + const abortController = new AbortController(); + vi.mocked(collectMemoryDiagnostics).mockImplementationOnce(async () => { + abortController.abort(); + return { + timestamp: '2026-05-01T10:00:00.000Z', + uptimeSeconds: 1, + memoryUsage: { + heapUsed: 1, + heapTotal: 1, + rss: 1, + external: 0, + arrayBuffers: 0, + }, + v8HeapStats: { + heapSizeLimit: 1, + totalHeapSize: 1, + usedHeapSize: 1, + mallocedMemory: 0, + peakMallocedMemory: 0, + detachedContexts: 0, + nativeContexts: 1, + }, + resourceUsage: { maxRSS: 0, userCPUTime: 0, systemCPUTime: 0 }, + activeHandles: 0, + activeRequests: 0, + platform: 'darwin', + nodeVersion: 'v20.19.0', + analysis: { risks: [], recommendation: '' }, + }; + }); + + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + abortSignal: abortController.signal, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, '--json'); + + expect(result).toBeUndefined(); + }); + + it('should render expanded fields in readable summary', async () => { + const result = await getMemoryCommand().action!(mockContext, ''); + const content = result?.type === 'message' ? result.content : ''; + + expect(content).toContain('detachedContexts: 0'); + expect(content).toContain('nativeContexts: 1'); + expect(content).toContain('maxRSS:'); + expect(content).toContain('userCPUTime:'); + expect(content).toContain('systemCPUTime:'); + expect(content).toContain('smapsRollup:'); + }); + + it('should advertise the memory subcommand on the parent doctor argumentHint', () => { + expect(doctorCommand.argumentHint).toBe('[memory]'); + }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index a16d4a8069a..d77719efdc2 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -236,12 +236,23 @@ export const doctorCommand: SlashCommand = { ], }; +const MEMORY_USAGE_HINT = '/doctor memory [--json]'; + async function memoryDoctorAction(context: CommandContext, args = '') { if (context.abortSignal?.aborted) { return; } const tokens = args.trim().split(/\s+/).filter(Boolean); + const unknown = tokens.filter((token) => token !== '--json'); + if (unknown.length > 0) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: `${t('Unknown argument(s)')}: ${unknown.join(', ')}. ${t('Usage')}: ${MEMORY_USAGE_HINT}`, + }; + } + try { const diagnostics = await collectMemoryDiagnostics(); @@ -264,21 +275,43 @@ async function memoryDoctorAction(context: CommandContext, args = '') { return { type: 'message' as const, messageType: 'error' as const, - content: `Failed to collect memory diagnostics: ${formatError(error)}`, + content: `${t('Failed to collect memory diagnostics')}: ${formatError(error)}`, }; } } +// resourceUsage CPU times are microseconds; convert to seconds for display. +function formatCpuMicroseconds(micros: number): string { + return `${(micros / 1_000_000).toFixed(2)}s`; +} + +function formatHeapSpaces( + spaces: MemoryDiagnostics['v8HeapSpaces'], +): string | undefined { + if (!spaces || spaces.length === 0) { + return undefined; + } + const top = [...spaces].sort((a, b) => b.used - a.used).slice(0, 4); + const lines = top.map( + (space) => + ` - ${space.name}: used ${formatMemoryUsage(space.used)} / size ${formatMemoryUsage(space.size)}`, + ); + if (spaces.length > top.length) { + lines.push(` - … ${spaces.length - top.length} more`); + } + return lines.join('\n'); +} + function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { const risks = diagnostics.analysis.risks.length > 0 ? diagnostics.analysis.risks .map((risk) => ` - ${risk.type}: ${risk.message}`) .join('\n') - : ' none'; + : ` ${t('none')}`; - return [ - 'Memory Diagnostics', + const lines: string[] = [ + t('Memory Diagnostics'), `timestamp: ${diagnostics.timestamp}`, `uptimeSeconds: ${diagnostics.uptimeSeconds.toFixed(1)}`, `heapUsed: ${formatMemoryUsage(diagnostics.memoryUsage.heapUsed)}`, @@ -289,13 +322,24 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { `v8HeapLimit: ${formatMemoryUsage(diagnostics.v8HeapStats.heapSizeLimit)}`, `v8MallocedMemory: ${formatMemoryUsage(diagnostics.v8HeapStats.mallocedMemory)}`, `v8PeakMallocedMemory: ${formatMemoryUsage(diagnostics.v8HeapStats.peakMallocedMemory)}`, + `detachedContexts: ${diagnostics.v8HeapStats.detachedContexts}`, + `nativeContexts: ${diagnostics.v8HeapStats.nativeContexts}`, + `maxRSS: ${formatMemoryUsage(diagnostics.resourceUsage.maxRSS)}`, + `userCPUTime: ${formatCpuMicroseconds(diagnostics.resourceUsage.userCPUTime)}`, + `systemCPUTime: ${formatCpuMicroseconds(diagnostics.resourceUsage.systemCPUTime)}`, `activeHandles: ${diagnostics.activeHandles}`, `activeRequests: ${diagnostics.activeRequests}`, - `openFileDescriptors: ${diagnostics.openFileDescriptors ?? 'unavailable'}`, - 'risks:', - risks, - `recommendation: ${diagnostics.analysis.recommendation}`, - ].join('\n'); + `openFileDescriptors: ${diagnostics.openFileDescriptors ?? t('unavailable')}`, + `smapsRollup: ${diagnostics.smapsRollup ? t('available') : t('unavailable')}`, + ]; + + const heapSpaces = formatHeapSpaces(diagnostics.v8HeapSpaces); + if (heapSpaces) { + lines.push('v8HeapSpaces:', heapSpaces); + } + + lines.push('risks:', risks, `recommendation: ${diagnostics.analysis.recommendation}`); + return lines.join('\n'); } function formatError(error: unknown): string { diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index 2f4ba0e2a4f..53474d947d4 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -28,20 +28,20 @@ describe('collectMemoryDiagnostics', () => { sessionId: 'session-123', qwenVersion: '0.15.6', memoryUsage: () => ({ - heapUsed: 1_600, - heapTotal: 2_000, - rss: 5_000, + heapUsed: 32 * 1024 * 1024, + heapTotal: 40 * 1024 * 1024, + rss: 100 * 1024 * 1024, external: 700, arrayBuffers: 300, }), heapStatistics: () => ({ - heap_size_limit: 2_000, - total_heap_size: 2_000, + heap_size_limit: 40 * 1024 * 1024, + total_heap_size: 40 * 1024 * 1024, total_heap_size_executable: 0, - total_physical_size: 2_000, - used_heap_size: 1_600, - malloced_memory: 4_000, - peak_malloced_memory: 4_500, + total_physical_size: 40 * 1024 * 1024, + used_heap_size: 32 * 1024 * 1024, + malloced_memory: 80 * 1024 * 1024, + peak_malloced_memory: 90 * 1024 * 1024, does_zap_garbage: 0, number_of_native_contexts: 2, number_of_detached_contexts: 1, @@ -78,7 +78,7 @@ describe('collectMemoryDiagnostics', () => { involuntaryContextSwitches: 0, }), uptimeSeconds: () => 60, - activeHandles: () => 101, + activeHandles: () => 300, activeRequests: () => 3, openFileDescriptors: async () => 501, smapsRollup: async () => 'Rss: 5000 kB', @@ -92,18 +92,18 @@ describe('collectMemoryDiagnostics', () => { qwenVersion: '0.15.6', uptimeSeconds: 60, memoryUsage: { - heapUsed: 1_600, - heapTotal: 2_000, - rss: 5_000, + heapUsed: 32 * 1024 * 1024, + heapTotal: 40 * 1024 * 1024, + rss: 100 * 1024 * 1024, external: 700, arrayBuffers: 300, }, v8HeapStats: { - heapSizeLimit: 2_000, - totalHeapSize: 2_000, - usedHeapSize: 1_600, - mallocedMemory: 4_000, - peakMallocedMemory: 4_500, + heapSizeLimit: 40 * 1024 * 1024, + totalHeapSize: 40 * 1024 * 1024, + usedHeapSize: 32 * 1024 * 1024, + mallocedMemory: 80 * 1024 * 1024, + peakMallocedMemory: 90 * 1024 * 1024, detachedContexts: 1, nativeContexts: 2, }, @@ -120,7 +120,7 @@ describe('collectMemoryDiagnostics', () => { userCPUTime: 10, systemCPUTime: 20, }, - activeHandles: 101, + activeHandles: 300, activeRequests: 3, openFileDescriptors: 501, smapsRollup: 'Rss: 5000 kB', @@ -143,9 +143,82 @@ describe('collectMemoryDiagnostics', () => { const nativeRisk = diagnostics.analysis.risks.find( (risk) => risk.type === 'native-memory-pressure', ); - expect(nativeRisk?.message).toContain('3.9 KB'); - expect(nativeRisk?.message).toContain('1.6 KB'); - expect(nativeRisk?.message).not.toContain('4000 bytes'); + expect(nativeRisk?.message).toContain('80.0 MB'); + expect(nativeRisk?.message).toContain('32.0 MB'); + }); + + it('does not flag native pressure when malloced memory is below the absolute floor', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 1_600, + heapTotal: 2_000, + rss: 5_000, + external: 700, + arrayBuffers: 300, + }), + heapStatistics: () => ({ + heap_size_limit: 2_000, + total_heap_size: 2_000, + total_heap_size_executable: 0, + total_physical_size: 2_000, + used_heap_size: 1_600, + // 32 MB malloced, well above 2× the tiny heap but below the 64 MB + // floor — should not flag as a leak indicator. + malloced_memory: 32 * 1024 * 1024, + peak_malloced_memory: 32 * 1024 * 1024, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 400, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 700, + }), + activeHandles: () => 0, + activeRequests: () => 0, + }); + + expect(diagnostics.analysis.risks).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'native-memory-pressure' }), + ]), + ); + }); + + it('does not flag active-handles below the 256 threshold', async () => { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + activeHandles: () => 200, + activeRequests: () => 0, + }); + + expect(diagnostics.analysis.risks).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'active-handles' }), + ]), + ); }); it('does not multiply maxRSS by 1024 on non-Linux platforms', async () => { diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index e212eb2deb0..9ec58f071f4 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -12,6 +12,13 @@ import { formatMemoryUsage } from './formatters.js'; const RSS_HEAP_GAP_RATIO = 10; const RSS_HEAP_GAP_MIN_BYTES = 256 * 1024 * 1024; +// Native pressure can look extreme during early startup when heap is tiny. +// Require an absolute floor before the ratio check so cold processes don't +// flag spurious risks. +const NATIVE_MEMORY_PRESSURE_MIN_BYTES = 64 * 1024 * 1024; +const ACTIVE_HANDLES_THRESHOLD = 256; +const ACTIVE_REQUESTS_THRESHOLD = 100; +const OPEN_FD_THRESHOLD = 500; const debugLogger = createDebugLogger('MEMORY_DIAGNOSTICS'); export interface MemoryDiagnostics { @@ -89,6 +96,10 @@ export interface MemoryDiagnosticsOptions { nodeVersion?: string; } +// `_getActiveHandles` / `_getActiveRequests` are undocumented Node internals. +// They've been stable for years but are not part of the public API and could +// change in a future Node release. Both call sites guard with try/catch and +// fall back to 0, so a removal degrades gracefully. interface ProcessInternals { _getActiveHandles?: () => unknown[]; _getActiveRequests?: () => unknown[]; @@ -259,14 +270,14 @@ function analyzeMemoryDiagnostics( }); } - if (diagnostics.activeHandles > 100) { + if (diagnostics.activeHandles > ACTIVE_HANDLES_THRESHOLD) { risks.push({ type: 'active-handles', message: `${diagnostics.activeHandles} active handle(s) detected.`, }); } - if (diagnostics.activeRequests > 100) { + if (diagnostics.activeRequests > ACTIVE_REQUESTS_THRESHOLD) { risks.push({ type: 'active-requests', message: `${diagnostics.activeRequests} active request(s) detected.`, @@ -275,7 +286,7 @@ function analyzeMemoryDiagnostics( if ( diagnostics.openFileDescriptors !== undefined && - diagnostics.openFileDescriptors > 500 + diagnostics.openFileDescriptors > OPEN_FD_THRESHOLD ) { risks.push({ type: 'fd-leak', @@ -285,9 +296,13 @@ function analyzeMemoryDiagnostics( // Use mallocedMemory instead of rss - heapUsed. RSS includes normal process // overhead such as code segments, shared libraries, stacks, and mapped files, - // which creates false positives on healthy Node.js processes. + // which creates false positives on healthy Node.js processes. Also gate on + // an absolute floor so tiny startup heaps don't trip the 2× ratio. const nativeMemory = diagnostics.v8HeapStats.mallocedMemory; - if (nativeMemory > diagnostics.memoryUsage.heapUsed * 2) { + if ( + nativeMemory >= NATIVE_MEMORY_PRESSURE_MIN_BYTES && + nativeMemory > diagnostics.memoryUsage.heapUsed * 2 + ) { risks.push({ type: 'native-memory-pressure', message: `V8 native malloced memory (${formatMemoryUsage(nativeMemory)}) is more than 2× heap used (${formatMemoryUsage(diagnostics.memoryUsage.heapUsed)}).`, From 7a8f3f12241e52d4ec7dbd1dd628ea2fee72e7e1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 19:26:00 +0800 Subject: [PATCH 07/12] fix(cli): harden memory doctor diagnostics --- packages/cli/src/nonInteractiveCliCommands.ts | 4 +- .../cli/src/ui/commands/doctorCommand.test.ts | 43 +++++++++++++++++-- packages/cli/src/ui/commands/doctorCommand.ts | 25 +++++++++-- .../src/ui/commands/insightCommand.test.ts | 2 +- packages/cli/src/ui/commands/types.ts | 4 +- .../ui/hooks/slashCommandProcessor.test.ts | 22 ++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 6 +++ packages/cli/src/ui/types.ts | 6 ++- packages/core/src/utils/memoryDiagnostics.ts | 16 +++---- 9 files changed, 107 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index d575cd1c838..180e94e888c 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -48,13 +48,13 @@ export type NonInteractiveSlashCommandResult = } | { type: 'message'; - messageType: 'info' | 'error'; + messageType: 'info' | 'warning' | 'error'; content: string; } | { type: 'stream_messages'; messages: AsyncGenerator< - { messageType: 'info' | 'error'; content: string }, + { messageType: 'info' | 'warning' | 'error'; content: string }, void, unknown >; diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 7a021ac39f6..af3df6b0526 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -128,6 +128,20 @@ describe('doctorCommand', () => { detachedContexts: 0, nativeContexts: 1, }, + v8HeapSpaces: [ + { + name: 'old_space', + size: 4_096, + used: 2_048, + available: 2_048, + }, + { + name: 'new_space', + size: 2_048, + used: 1_024, + available: 1_024, + }, + ], resourceUsage: { maxRSS: 4_000, userCPUTime: 10, @@ -135,6 +149,7 @@ describe('doctorCommand', () => { }, activeHandles: 2, activeRequests: 0, + smapsRollup: 'Rss: 5000 kB\nPss: 1000 kB\n', platform: 'darwin', nodeVersion: 'v20.19.0', analysis: { @@ -722,10 +737,30 @@ describe('doctorCommand', () => { 'heapUsed: 1.0 KB', ); expect(result?.type === 'message' ? result.content : '').not.toContain( - '0.00 MiB', + 'heapUsed: 0.0 MB', ); }); + it('should pass session metadata to memory diagnostics', async () => { + const getSessionId = vi.fn(() => 'session-123'); + const getCliVersion = vi.fn(() => '0.15.11'); + mockContext = createMockCommandContext({ + services: { + config: { + getSessionId, + getCliVersion, + }, + }, + } as unknown as CommandContext); + + await getMemoryCommand().action!(mockContext, '--json'); + + expect(collectMemoryDiagnostics).toHaveBeenCalledWith({ + sessionId: 'session-123', + qwenVersion: '0.15.11', + }); + }); + it('should register memory as a real doctor subcommand', () => { expect(doctorCommand.subCommands?.map((command) => command.name)).toContain( 'memory', @@ -772,7 +807,7 @@ describe('doctorCommand', () => { expect(result).toEqual( expect.objectContaining({ type: 'message', - messageType: 'info', + messageType: 'warning', }), ); expect(result?.type === 'message' ? result.content : '').toContain( @@ -885,7 +920,9 @@ describe('doctorCommand', () => { expect(content).toContain('maxRSS:'); expect(content).toContain('userCPUTime:'); expect(content).toContain('systemCPUTime:'); - expect(content).toContain('smapsRollup:'); + expect(content).toContain('smapsRollup: Rss: 5000 kB'); + expect(content).toContain('v8HeapSpaces:'); + expect(content).toContain('old_space: used 2.0 KB'); }); it('should advertise the memory subcommand on the parent doctor argumentHint', () => { diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index d77719efdc2..4770d437d11 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -254,7 +254,10 @@ async function memoryDoctorAction(context: CommandContext, args = '') { } try { - const diagnostics = await collectMemoryDiagnostics(); + const diagnostics = await collectMemoryDiagnostics({ + sessionId: context.services.config?.getSessionId(), + qwenVersion: context.services.config?.getCliVersion(), + }); if (context.abortSignal?.aborted) { return; @@ -262,7 +265,10 @@ async function memoryDoctorAction(context: CommandContext, args = '') { return { type: 'message' as const, - messageType: 'info' as const, + messageType: + diagnostics.analysis.risks.length > 0 + ? ('warning' as const) + : ('info' as const), content: tokens.includes('--json') ? JSON.stringify(diagnostics, null, 2) : formatMemoryDiagnostics(diagnostics), @@ -302,6 +308,19 @@ function formatHeapSpaces( return lines.join('\n'); } +function formatSmapsRollup(smapsRollup: string | undefined): string { + if (!smapsRollup) { + return t('unavailable'); + } + + const rssLine = smapsRollup + .split(/\r?\n/) + .map((line) => line.trim().replace(/\s+/g, ' ')) + .find((line) => line.startsWith('Rss:')); + + return rssLine ?? t('available'); +} + function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { const risks = diagnostics.analysis.risks.length > 0 @@ -330,7 +349,7 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { `activeHandles: ${diagnostics.activeHandles}`, `activeRequests: ${diagnostics.activeRequests}`, `openFileDescriptors: ${diagnostics.openFileDescriptors ?? t('unavailable')}`, - `smapsRollup: ${diagnostics.smapsRollup ? t('available') : t('unavailable')}`, + `smapsRollup: ${formatSmapsRollup(diagnostics.smapsRollup)}`, ]; const heapSpaces = formatHeapSpaces(diagnostics.v8HeapSpaces); diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts index 43752ec4c40..22f230fed75 100644 --- a/packages/cli/src/ui/commands/insightCommand.test.ts +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -120,7 +120,7 @@ describe('insightCommand', () => { const messagesPromise = (async () => { const messages: Array<{ - messageType: 'info' | 'error'; + messageType: 'info' | 'warning' | 'error'; content: string; }> = []; for await (const message of result.messages) { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 26b9054725c..a7454ea9752 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -131,7 +131,7 @@ export interface QuitActionReturn { */ export interface MessageActionReturn { type: 'message'; - messageType: 'info' | 'error'; + messageType: 'info' | 'warning' | 'error'; content: string; } @@ -142,7 +142,7 @@ export interface MessageActionReturn { export interface StreamMessagesActionReturn { type: 'stream_messages'; messages: AsyncGenerator< - { messageType: 'info' | 'error'; content: string }, + { messageType: 'info' | 'warning' | 'error'; content: string }, void, unknown >; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 57cc7048ba0..82e2beecd59 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -334,6 +334,28 @@ describe('useSlashCommandProcessor', () => { ); }); + it('should display warning message command results as warnings', async () => { + const command = createTestCommand({ + name: 'warn', + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'warning', + content: 'Check diagnostics.', + }), + }); + const result = setupProcessorHook([command]); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/warn'); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { type: MessageType.WARNING, text: 'Check diagnostics.' }, + expect.any(Number), + ); + }); + it('should correctly find and execute a nested subcommand', async () => { const childAction = vi.fn(); const parentCommand: SlashCommand = { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index a4bfe44b52b..0607778cddf 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -640,6 +640,12 @@ export const useSlashCommandProcessor = ( content: result.content, timestamp: new Date(), }); + } else if (result.messageType === 'warning') { + addMessage({ + type: MessageType.WARNING, + content: result.content, + timestamp: new Date(), + }); } else { addMessage({ type: MessageType.ERROR, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index f040b3d2dc1..5a180130b53 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -606,7 +606,11 @@ export interface InsightProgressProps { // Simplified message structure for internal feedback export type Message = | { - type: MessageType.INFO | MessageType.ERROR | MessageType.USER; + type: + | MessageType.INFO + | MessageType.WARNING + | MessageType.ERROR + | MessageType.USER; content: string; // Renamed from text for clarity in this context timestamp: Date; } diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index 9ec58f071f4..7814f79dce2 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -133,7 +133,7 @@ export async function collectMemoryDiagnostics( const maxRSSBytes = platform === 'linux' ? resourceUsage.maxRSS * 1024 : resourceUsage.maxRSS; - const diagnostics: MemoryDiagnostics = { + const diagnostics = { timestamp: now().toISOString(), sessionId: options.sessionId, qwenVersion: options.qwenVersion, @@ -152,14 +152,12 @@ export async function collectMemoryDiagnostics( smapsRollup, platform, nodeVersion: options.nodeVersion ?? process.version, - analysis: { - risks: [], - recommendation: '', - }, - }; + } satisfies Omit; - diagnostics.analysis = analyzeMemoryDiagnostics(diagnostics); - return diagnostics; + return { + ...diagnostics, + analysis: analyzeMemoryDiagnostics(diagnostics), + }; } function mapHeapStats(heapInfo: v8.HeapInfo): V8HeapStats { @@ -248,7 +246,7 @@ function logProbeFailure(name: string, error: unknown): void { } function analyzeMemoryDiagnostics( - diagnostics: MemoryDiagnostics, + diagnostics: Omit, ): MemoryDiagnosticsAnalysis { const risks: MemoryRisk[] = []; const heapRatio = From 850632daa5744f33274d2d6cdbc68290b81ac159 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 16 May 2026 23:26:44 +0800 Subject: [PATCH 08/12] fix(core): correct maxRSS byte handling and heapRatio consistency - Remove incorrect * 1024 multiplier for maxRSS on Linux (Node.js >=14.10 returns bytes on all platforms) - Use v8HeapStats.usedHeapSize for heapRatio to avoid cross-API inconsistency - Update test expectations and rename "does not multiply" test --- packages/core/src/utils/memoryDiagnostics.test.ts | 6 +++--- packages/core/src/utils/memoryDiagnostics.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index 53474d947d4..b3e2d610483 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -116,7 +116,7 @@ describe('collectMemoryDiagnostics', () => { }, ], resourceUsage: { - maxRSS: 6 * 1024, + maxRSS: 6, userCPUTime: 10, systemCPUTime: 20, }, @@ -221,7 +221,7 @@ describe('collectMemoryDiagnostics', () => { ); }); - it('does not multiply maxRSS by 1024 on non-Linux platforms', async () => { + it('treats maxRSS as bytes on all platforms', async () => { const diagnostics = await collectMemoryDiagnostics({ memoryUsage: () => ({ heapUsed: 100, @@ -268,7 +268,7 @@ describe('collectMemoryDiagnostics', () => { nodeVersion: 'v20.19.0', }); - // On macOS, maxRSS is already in bytes — no ×1024 conversion. + // Node.js >=14.10.0 returns maxRSS in bytes on all platforms. expect(diagnostics.resourceUsage.maxRSS).toBe(4_096); }); diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index 7814f79dce2..7e7774bab43 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -128,10 +128,9 @@ export async function collectMemoryDiagnostics( ]); const v8HeapSpaces = mapHeapSpaces(heapSpaceStatistics); - // process.resourceUsage().maxRSS is in kilobytes on Linux but bytes on - // macOS/Windows. Normalise to bytes for a consistent diagnostic unit. - const maxRSSBytes = - platform === 'linux' ? resourceUsage.maxRSS * 1024 : resourceUsage.maxRSS; + // Node.js >=14.10.0 returns maxRSS in bytes on all platforms. + // This project requires Node >=22. + const maxRSSBytes = resourceUsage.maxRSS; const diagnostics = { timestamp: now().toISOString(), @@ -251,7 +250,8 @@ function analyzeMemoryDiagnostics( const risks: MemoryRisk[] = []; const heapRatio = diagnostics.v8HeapStats.heapSizeLimit > 0 - ? diagnostics.memoryUsage.heapUsed / diagnostics.v8HeapStats.heapSizeLimit + ? diagnostics.v8HeapStats.usedHeapSize / + diagnostics.v8HeapStats.heapSizeLimit : 0; if (heapRatio >= 0.75) { From 6652cb53dbaf107705e1d3e1a05fac071c80b339 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 01:04:28 +0800 Subject: [PATCH 09/12] fix(cli): resolve rebase conflicts in memory diagnostics - Rename local formatMemoryDiagnostics to formatCoreDiagnostics to avoid naming conflict with the imported utility from memoryDiagnostics.js - Update Session.test.ts to use objectContaining for _meta field added in recent main commits - Align doctorCommand.test.ts assertions with current parent command state (argumentHint includes --sample/--snapshot from main) --- .../acp-integration/session/Session.test.ts | 56 ++++++++++--------- .../cli/src/ui/commands/doctorCommand.test.ts | 3 +- packages/cli/src/ui/commands/doctorCommand.ts | 10 +++- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index aba61cbd8f2..7cd65f25970 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -579,19 +579,21 @@ describe('Session', () => { await session.sendAvailableCommandsUpdate(); - expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ - sessionId: 'test-session-id', - update: { - sessionUpdate: 'available_commands_update', - availableCommands: [ - { - name: 'doctor', - description: 'Run installation and environment diagnostics', - input: null, - }, - ], - }, - }); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'test-session-id', + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: expect.arrayContaining([ + expect.objectContaining({ + name: 'doctor', + description: 'Run installation and environment diagnostics', + input: null, + }), + ]), + }), + }), + ); }); it('honors explicit input override for built-in commands without input metadata', async () => { @@ -606,19 +608,21 @@ describe('Session', () => { await session.sendAvailableCommandsUpdate(); - expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ - sessionId: 'test-session-id', - update: { - sessionUpdate: 'available_commands_update', - availableCommands: [ - { - name: 'diagnostics', - description: 'Run diagnostics', - input: { hint: '' }, - }, - ], - }, - }); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'test-session-id', + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: expect.arrayContaining([ + expect.objectContaining({ + name: 'diagnostics', + description: 'Run diagnostics', + input: { hint: '' }, + }), + ]), + }), + }), + ); }); it('attaches available skills to available_commands_update metadata', async () => { diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index af3df6b0526..0a4e9d373a6 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -168,7 +168,6 @@ describe('doctorCommand', () => { expect(doctorCommand.description).toBe( 'Run installation and environment diagnostics', ); - expect(doctorCommand.acceptsInput).toBe(false); }); it('should complete memory subcommand names', async () => { @@ -926,6 +925,6 @@ describe('doctorCommand', () => { }); it('should advertise the memory subcommand on the parent doctor argumentHint', () => { - expect(doctorCommand.argumentHint).toBe('[memory]'); + expect(doctorCommand.argumentHint).toBe('[memory] [--sample] [--snapshot]'); }); }); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 4770d437d11..b397cc5e8b1 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -271,7 +271,7 @@ async function memoryDoctorAction(context: CommandContext, args = '') { : ('info' as const), content: tokens.includes('--json') ? JSON.stringify(diagnostics, null, 2) - : formatMemoryDiagnostics(diagnostics), + : formatCoreDiagnostics(diagnostics), }; } catch (error) { if (context.abortSignal?.aborted) { @@ -321,7 +321,7 @@ function formatSmapsRollup(smapsRollup: string | undefined): string { return rssLine ?? t('available'); } -function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { +function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string { const risks = diagnostics.analysis.risks.length > 0 ? diagnostics.analysis.risks @@ -357,7 +357,11 @@ function formatMemoryDiagnostics(diagnostics: MemoryDiagnostics): string { lines.push('v8HeapSpaces:', heapSpaces); } - lines.push('risks:', risks, `recommendation: ${diagnostics.analysis.recommendation}`); + lines.push( + 'risks:', + risks, + `recommendation: ${diagnostics.analysis.recommendation}`, + ); return lines.join('\n'); } From a484fd53e8a1eb3baf2e7fd61c481ff6d79885da Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 01:23:32 +0800 Subject: [PATCH 10/12] fix(core): use null instead of undefined for optional probes, deduplicate active count helpers - optionalProbe/optionalSyncProbe now return null on failure so JSON.stringify preserves the keys instead of silently omitting them. - Merge getActiveHandlesCount/getActiveRequestsCount into a single parameterized getProcessInternalCount helper. - Update MemoryDiagnostics interface: v8HeapSpaces, openFileDescriptors, smapsRollup are now T | null instead of T | undefined. --- .../cli/src/ui/commands/doctorCommand.test.ts | 7 +++ packages/cli/src/ui/commands/doctorCommand.ts | 2 +- .../core/src/utils/memoryDiagnostics.test.ts | 6 +- packages/core/src/utils/memoryDiagnostics.ts | 60 ++++++++++--------- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 0a4e9d373a6..10c4e9e7149 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -149,6 +149,7 @@ describe('doctorCommand', () => { }, activeHandles: 2, activeRequests: 0, + openFileDescriptors: null, smapsRollup: 'Rss: 5000 kB\nPss: 1000 kB\n', platform: 'darwin', nodeVersion: 'v20.19.0', @@ -794,6 +795,9 @@ describe('doctorCommand', () => { }, activeHandles: 2, activeRequests: 0, + v8HeapSpaces: null, + openFileDescriptors: null, + smapsRollup: null, platform: 'darwin', nodeVersion: 'v20.19.0', analysis: { @@ -890,6 +894,9 @@ describe('doctorCommand', () => { resourceUsage: { maxRSS: 0, userCPUTime: 0, systemCPUTime: 0 }, activeHandles: 0, activeRequests: 0, + v8HeapSpaces: null, + openFileDescriptors: null, + smapsRollup: null, platform: 'darwin', nodeVersion: 'v20.19.0', analysis: { risks: [], recommendation: '' }, diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index b397cc5e8b1..0ad3de12fc3 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -308,7 +308,7 @@ function formatHeapSpaces( return lines.join('\n'); } -function formatSmapsRollup(smapsRollup: string | undefined): string { +function formatSmapsRollup(smapsRollup: string | null): string { if (!smapsRollup) { return t('unavailable'); } diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index b3e2d610483..3d0933121e1 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -310,9 +310,9 @@ describe('collectMemoryDiagnostics', () => { }, }); - expect(diagnostics.v8HeapSpaces).toBeUndefined(); - expect(diagnostics.openFileDescriptors).toBeUndefined(); - expect(diagnostics.smapsRollup).toBeUndefined(); + expect(diagnostics.v8HeapSpaces).toBeNull(); + expect(diagnostics.openFileDescriptors).toBeNull(); + expect(diagnostics.smapsRollup).toBeNull(); expect(diagnostics.analysis.risks).toEqual([]); expect(diagnostics.analysis.recommendation).toContain( 'No obvious leak indicators', diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index 7e7774bab43..e1410ddcb10 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -28,12 +28,12 @@ export interface MemoryDiagnostics { uptimeSeconds: number; memoryUsage: NodeJS.MemoryUsage; v8HeapStats: V8HeapStats; - v8HeapSpaces?: V8HeapSpaceStats[]; + v8HeapSpaces: V8HeapSpaceStats[] | null; resourceUsage: MemoryResourceUsage; activeHandles: number; activeRequests: number; - openFileDescriptors?: number; - smapsRollup?: string; + openFileDescriptors: number | null; + smapsRollup: string | null; platform: NodeJS.Platform; nodeVersion: string; analysis: MemoryDiagnosticsAnalysis; @@ -145,8 +145,16 @@ export async function collectMemoryDiagnostics( userCPUTime: resourceUsage.userCPUTime, systemCPUTime: resourceUsage.systemCPUTime, }, - activeHandles: getActiveHandlesCount(options.activeHandles), - activeRequests: getActiveRequestsCount(options.activeRequests), + activeHandles: getProcessInternalCount( + 'activeHandles', + '_getActiveHandles', + options.activeHandles, + ), + activeRequests: getProcessInternalCount( + 'activeRequests', + '_getActiveRequests', + options.activeRequests, + ), openFileDescriptors, smapsRollup, platform, @@ -172,9 +180,12 @@ function mapHeapStats(heapInfo: v8.HeapInfo): V8HeapStats { } function mapHeapSpaces( - heapSpaces: v8.HeapSpaceInfo[] | undefined, -): V8HeapSpaceStats[] | undefined { - return heapSpaces?.map((space) => ({ + heapSpaces: v8.HeapSpaceInfo[] | null, +): V8HeapSpaceStats[] | null { + if (!heapSpaces) { + return null; + } + return heapSpaces.map((space) => ({ name: space.space_name, size: space.space_size, used: space.space_used_size, @@ -182,28 +193,19 @@ function mapHeapSpaces( })); } -function getActiveHandlesCount(probe?: () => number): number { - try { - if (probe) { - return probe(); - } - const internals = process as unknown as ProcessInternals; - return internals._getActiveHandles?.().length ?? 0; - } catch (error) { - logProbeFailure('activeHandles', error); - return 0; - } -} - -function getActiveRequestsCount(probe?: () => number): number { +function getProcessInternalCount( + name: 'activeHandles' | 'activeRequests', + internalMethod: '_getActiveHandles' | '_getActiveRequests', + probe?: () => number, +): number { try { if (probe) { return probe(); } const internals = process as unknown as ProcessInternals; - return internals._getActiveRequests?.().length ?? 0; + return internals[internalMethod]?.().length ?? 0; } catch (error) { - logProbeFailure('activeRequests', error); + logProbeFailure(name, error); return 0; } } @@ -219,24 +221,24 @@ async function readProcSmapsRollup(): Promise { async function optionalProbe( name: string, probe: () => Promise, -): Promise { +): Promise { try { return await probe(); } catch (error) { logProbeFailure(name, error); - return undefined; + return null; } } async function optionalSyncProbe( name: string, probe: () => T, -): Promise { +): Promise { try { return probe(); } catch (error) { logProbeFailure(name, error); - return undefined; + return null; } } @@ -283,7 +285,7 @@ function analyzeMemoryDiagnostics( } if ( - diagnostics.openFileDescriptors !== undefined && + diagnostics.openFileDescriptors !== null && diagnostics.openFileDescriptors > OPEN_FD_THRESHOLD ) { risks.push({ From e8d891df8145eeb86d93f4b68c30e46724f1c79b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 12:01:12 +0800 Subject: [PATCH 11/12] fix(cli): finish memory diagnostics review fixes --- packages/cli/src/i18n/locales/en.js | 2 ++ packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/core/src/utils/memoryDiagnostics.test.ts | 7 +++++-- packages/core/src/utils/memoryDiagnostics.ts | 2 +- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 29db173bc27..fb4ff837bae 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1894,6 +1894,8 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': 'Open the memory manager.', + 'Show current process memory diagnostics': + 'Show current process memory diagnostics', '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 b6ddc78cbec..17af2229a45 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1482,6 +1482,7 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': '打開記憶管理器。', + 'Show current process memory diagnostics': '顯示目前程序的內存診斷。', '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 bb804745c3a..ce915867390 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1717,6 +1717,7 @@ export default { '[{{label}}] failed: {{error}}': '[{{label}}] 失败:{{error}}', 'Loading suggestions...': '正在加载建议...', 'Open the memory manager.': '打开记忆管理器。', + 'Show current process memory diagnostics': '显示当前进程的内存诊断。', 'Save a durable memory to the memory system.': '将一条持久记忆保存到记忆系统。', 'Show per-item context usage breakdown.': '显示按项目划分的上下文使用详情。', diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index 3d0933121e1..ca62f631a2c 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -314,8 +314,11 @@ describe('collectMemoryDiagnostics', () => { expect(diagnostics.openFileDescriptors).toBeNull(); expect(diagnostics.smapsRollup).toBeNull(); expect(diagnostics.analysis.risks).toEqual([]); - expect(diagnostics.analysis.recommendation).toContain( - 'No obvious leak indicators', + expect(diagnostics.analysis.recommendation).toBe( + 'No obvious leak indicators detected.', + ); + expect(diagnostics.analysis.recommendation).not.toContain( + 'heap snapshot', ); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('heapSpaceStatistics'), diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index e1410ddcb10..f4a71df14a3 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -326,6 +326,6 @@ function analyzeMemoryDiagnostics( recommendation: risks.length > 0 ? `WARNING: ${risks.length} potential leak indicator(s) found.` - : 'No obvious leak indicators. Check heap snapshot for retained objects.', + : 'No obvious leak indicators detected.', }; } From b3921c20ff045e6da55eaf72f2ac870e32532ba2 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 17 May 2026 14:00:54 +0800 Subject: [PATCH 12/12] fix(cli): address memory diagnostics review feedback --- .../cli/src/ui/commands/doctorCommand.test.ts | 107 +++++++++++++++++- packages/cli/src/ui/commands/doctorCommand.ts | 32 ++++-- .../core/src/utils/memoryDiagnostics.test.ts | 62 +++++++++- packages/core/src/utils/memoryDiagnostics.ts | 23 +++- 4 files changed, 205 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts index 10c4e9e7149..f9afbd969c1 100644 --- a/packages/cli/src/ui/commands/doctorCommand.test.ts +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -765,7 +765,57 @@ describe('doctorCommand', () => { expect(doctorCommand.subCommands?.map((command) => command.name)).toContain( 'memory', ); - expect(getMemoryCommand().argumentHint).toBe('[--json]'); + expect(getMemoryCommand().argumentHint).toBe( + '[--json] [--sample] [--snapshot]', + ); + }); + + it('should support sampled memory diagnostics through the memory subcommand', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, '--sample'); + + expect( + memoryDiagnosticsModule.collectMemoryPressureSamples, + ).toHaveBeenCalledWith({ + sampleCount: 3, + intervalMs: 1000, + signal: undefined, + }); + expect(collectMemoryDiagnostics).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Memory diagnostics\nRSS: 100.0 MiB\nActive handles: 3\n\nMemory pressure samples\nSample count: 1', + }); + }); + + it('should support heap snapshots through the memory subcommand', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + }, + } as unknown as CommandContext); + + const result = await getMemoryCommand().action!(mockContext, '--snapshot'); + + expect(memoryDiagnosticsModule.writeMemoryHeapSnapshot).toHaveBeenCalled(); + expect(collectMemoryDiagnostics).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Memory diagnostics\nRSS: 100.0 MiB\nActive handles: 3\n\nHeap snapshot written: /tmp/qwen-code-heap.heapsnapshot\nHeap snapshot may contain prompts, file contents, tool results, and other sensitive data. Do not share it publicly without reviewing it first.', + }); }); it('should render risk indicators without failing memory diagnostics', async () => { @@ -802,7 +852,7 @@ describe('doctorCommand', () => { nodeVersion: 'v20.19.0', analysis: { risks: [{ type: 'heap-pressure', message: 'Heap pressure detected.' }], - recommendation: 'WARNING: 1 potential leak indicator(s) found.', + recommendation: '1 potential leak indicator(s) found.', }, }); const result = await getMemoryCommand().action!(mockContext, ''); @@ -816,6 +866,12 @@ describe('doctorCommand', () => { expect(result?.type === 'message' ? result.content : '').toContain( 'heap-pressure: Heap pressure detected.', ); + expect(result?.type === 'message' ? result.content : '').toContain( + 'recommendation: 1 potential leak indicator(s) found.', + ); + expect(result?.type === 'message' ? result.content : '').not.toContain( + 'recommendation: WARNING:', + ); }); it('should skip memory diagnostics when already aborted', async () => { @@ -864,10 +920,55 @@ describe('doctorCommand', () => { }), ); expect(result?.type === 'message' ? result.content : '').toContain( - '/doctor memory [--json]', + '/doctor memory [--json] [--sample] [--snapshot]', ); }); + it('should show a parse error marker for malformed smaps rollup data', async () => { + vi.mocked(collectMemoryDiagnostics).mockResolvedValueOnce({ + timestamp: '2026-05-01T10:00:00.000Z', + uptimeSeconds: 60, + memoryUsage: { + heapUsed: 1_000, + heapTotal: 2_000, + rss: 3_000, + external: 100, + arrayBuffers: 50, + }, + v8HeapStats: { + heapSizeLimit: 4_000, + totalHeapSize: 2_000, + usedHeapSize: 1_000, + mallocedMemory: 2_048, + peakMallocedMemory: 4_096, + detachedContexts: 0, + nativeContexts: 1, + }, + v8HeapSpaces: null, + resourceUsage: { + maxRSS: 4_000, + userCPUTime: 10, + systemCPUTime: 20, + }, + activeHandles: 2, + activeRequests: 0, + openFileDescriptors: null, + smapsRollup: 'Pss: 1000 kB\n', + platform: 'linux', + nodeVersion: 'v20.19.0', + analysis: { + risks: [], + recommendation: 'No obvious leak indicators.', + }, + }); + + const result = await getMemoryCommand().action!(mockContext, ''); + const content = result?.type === 'message' ? result.content : ''; + + expect(content).toContain('smapsRollup: parse error: Pss:'); + expect(content).not.toContain('smapsRollup: available'); + }); + it('should suppress JSON output when aborted between probe and return', async () => { const abortController = new AbortController(); vi.mocked(collectMemoryDiagnostics).mockImplementationOnce(async () => { diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index 0ad3de12fc3..bb90c389974 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -230,13 +230,13 @@ export const doctorCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - argumentHint: '[--json]', + argumentHint: '[--json] [--sample] [--snapshot]', action: memoryDoctorAction, }, ], }; -const MEMORY_USAGE_HINT = '/doctor memory [--json]'; +const MEMORY_USAGE_HINT = '/doctor memory [--json] [--sample] [--snapshot]'; async function memoryDoctorAction(context: CommandContext, args = '') { if (context.abortSignal?.aborted) { @@ -244,7 +244,10 @@ async function memoryDoctorAction(context: CommandContext, args = '') { } const tokens = args.trim().split(/\s+/).filter(Boolean); - const unknown = tokens.filter((token) => token !== '--json'); + const unknown = tokens.filter( + (token) => + token !== '--json' && token !== '--sample' && token !== '--snapshot', + ); if (unknown.length > 0) { return { type: 'message' as const, @@ -253,6 +256,17 @@ async function memoryDoctorAction(context: CommandContext, args = '') { }; } + const shouldSampleMemory = tokens.includes('--sample'); + const shouldWriteHeapSnapshot = tokens.includes('--snapshot'); + if (shouldSampleMemory || shouldWriteHeapSnapshot) { + return doctorCommand.action?.( + context, + [MEMORY_SUBCOMMAND, ...tokens.filter((token) => token !== '--json')].join( + ' ', + ), + ); + } + try { const diagnostics = await collectMemoryDiagnostics({ sessionId: context.services.config?.getSessionId(), @@ -281,7 +295,7 @@ async function memoryDoctorAction(context: CommandContext, args = '') { return { type: 'message' as const, messageType: 'error' as const, - content: `${t('Failed to collect memory diagnostics')}: ${formatError(error)}`, + content: `${t('Failed to collect memory diagnostics')}: ${formatErrorMessage(error)}`, }; } } @@ -317,8 +331,12 @@ function formatSmapsRollup(smapsRollup: string | null): string { .split(/\r?\n/) .map((line) => line.trim().replace(/\s+/g, ' ')) .find((line) => line.startsWith('Rss:')); + if (rssLine) { + return rssLine; + } - return rssLine ?? t('available'); + const preview = smapsRollup.slice(0, 80).trim().replace(/\s+/g, ' '); + return `${t('parse error')}: ${preview}`; } function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string { @@ -364,7 +382,3 @@ function formatCoreDiagnostics(diagnostics: MemoryDiagnostics): string { ); return lines.join('\n'); } - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/core/src/utils/memoryDiagnostics.test.ts b/packages/core/src/utils/memoryDiagnostics.test.ts index ca62f631a2c..0e7c3de4a01 100644 --- a/packages/core/src/utils/memoryDiagnostics.test.ts +++ b/packages/core/src/utils/memoryDiagnostics.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest'; +import process from 'node:process'; const debugLogger = vi.hoisted(() => ({ debug: vi.fn(), @@ -145,6 +146,10 @@ describe('collectMemoryDiagnostics', () => { ); expect(nativeRisk?.message).toContain('80.0 MB'); expect(nativeRisk?.message).toContain('32.0 MB'); + expect(diagnostics.analysis.recommendation).toBe( + '5 potential leak indicator(s) found.', + ); + expect(diagnostics.analysis.recommendation).not.toContain('WARNING:'); }); it('does not flag native pressure when malloced memory is below the absolute floor', async () => { @@ -317,9 +322,7 @@ describe('collectMemoryDiagnostics', () => { expect(diagnostics.analysis.recommendation).toBe( 'No obvious leak indicators detected.', ); - expect(diagnostics.analysis.recommendation).not.toContain( - 'heap snapshot', - ); + expect(diagnostics.analysis.recommendation).not.toContain('heap snapshot'); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('heapSpaceStatistics'), expect.any(Error), @@ -372,6 +375,59 @@ describe('collectMemoryDiagnostics', () => { expect(diagnostics.analysis.risks).toEqual([]); }); + it('logs unavailable Node.js internal active probes before returning zero counts', async () => { + const internals = process as typeof process & { + _getActiveHandles?: () => unknown[]; + _getActiveRequests?: () => unknown[]; + }; + const originalGetActiveHandles = internals._getActiveHandles; + const originalGetActiveRequests = internals._getActiveRequests; + internals._getActiveHandles = undefined; + internals._getActiveRequests = undefined; + + try { + const diagnostics = await collectMemoryDiagnostics({ + memoryUsage: () => ({ + heapUsed: 100, + heapTotal: 200, + rss: 300, + external: 10, + arrayBuffers: 5, + }), + heapStatistics: () => ({ + heap_size_limit: 1_000, + total_heap_size: 200, + total_heap_size_executable: 0, + total_physical_size: 200, + used_heap_size: 100, + malloced_memory: 0, + peak_malloced_memory: 0, + does_zap_garbage: 0, + number_of_native_contexts: 1, + number_of_detached_contexts: 0, + total_available_size: 900, + total_global_handles_size: 0, + used_global_handles_size: 0, + external_memory: 10, + }), + }); + + expect(diagnostics.activeHandles).toBe(0); + expect(diagnostics.activeRequests).toBe(0); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('activeHandles'), + expect.any(Error), + ); + expect(debugLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('activeRequests'), + expect.any(Error), + ); + } finally { + internals._getActiveHandles = originalGetActiveHandles; + internals._getActiveRequests = originalGetActiveRequests; + } + }); + it('starts independent optional probes before awaiting slow probes', async () => { let resolveFileDescriptors: ((count: number) => void) | undefined; const fileDescriptors = new Promise((resolve) => { diff --git a/packages/core/src/utils/memoryDiagnostics.ts b/packages/core/src/utils/memoryDiagnostics.ts index f4a71df14a3..2ebe3c88e6e 100644 --- a/packages/core/src/utils/memoryDiagnostics.ts +++ b/packages/core/src/utils/memoryDiagnostics.ts @@ -101,8 +101,8 @@ export interface MemoryDiagnosticsOptions { // change in a future Node release. Both call sites guard with try/catch and // fall back to 0, so a removal degrades gracefully. interface ProcessInternals { - _getActiveHandles?: () => unknown[]; - _getActiveRequests?: () => unknown[]; + _getActiveHandles?: () => unknown; + _getActiveRequests?: () => unknown; } export async function collectMemoryDiagnostics( @@ -203,7 +203,22 @@ function getProcessInternalCount( return probe(); } const internals = process as unknown as ProcessInternals; - return internals[internalMethod]?.().length ?? 0; + const internalProbe = internals[internalMethod]; + if (typeof internalProbe !== 'function') { + logProbeFailure(name, new Error(`${internalMethod} is unavailable`)); + return 0; + } + + const result = internalProbe(); + if (!Array.isArray(result)) { + logProbeFailure( + name, + new Error(`${internalMethod} returned a non-array result`), + ); + return 0; + } + + return result.length; } catch (error) { logProbeFailure(name, error); return 0; @@ -325,7 +340,7 @@ function analyzeMemoryDiagnostics( risks, recommendation: risks.length > 0 - ? `WARNING: ${risks.length} potential leak indicator(s) found.` + ? `${risks.length} potential leak indicator(s) found.` : 'No obvious leak indicators detected.', }; }