From 5a8747332b43b2ed12f673f654af39f1fd44a785 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 31 May 2026 16:01:25 +0800 Subject: [PATCH 1/5] feat(core): auto-dump memory diagnostics to disk on pressure detection When the MemoryPressureMonitor (#4403) detects hard or critical pressure, write a lightweight diagnostics JSON to .qwen//diagnostics/ before running cleanup. The file survives even if a subsequent operation triggers OOM, giving maintainers actionable data from bug reports without requiring the user to manually run /doctor memory after a crash. Design follows Claude Code's heapDumpService approach: write the cheap JSON first (small write, won't OOM), heavy snapshot second. Diagnostics include process memory stats, V8 heap stats, session history size, and an actionable suggestion for the user. Per-session limits: max 3 dumps, 30s cooldown between dumps. Closes #4651 --- .../services/memoryDiagnosticsDumper.test.ts | 176 ++++++++++++++++++ .../src/services/memoryDiagnosticsDumper.ts | 139 ++++++++++++++ .../src/services/memoryPressureMonitor.ts | 10 + 3 files changed, 325 insertions(+) create mode 100644 packages/core/src/services/memoryDiagnosticsDumper.test.ts create mode 100644 packages/core/src/services/memoryDiagnosticsDumper.ts diff --git a/packages/core/src/services/memoryDiagnosticsDumper.test.ts b/packages/core/src/services/memoryDiagnosticsDumper.test.ts new file mode 100644 index 00000000000..4019b1ec6ed --- /dev/null +++ b/packages/core/src/services/memoryDiagnosticsDumper.test.ts @@ -0,0 +1,176 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import { MemoryDiagnosticsDumper } from './memoryDiagnosticsDumper.js'; +import type { Config } from '../config/config.js'; + +vi.mock('node:fs', () => ({ + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('../utils/memoryDiagnostics.js', () => ({ + collectMemoryDiagnostics: vi.fn().mockResolvedValue({ + timestamp: '2026-05-31T00:00:00.000Z', + memoryUsage: { + rss: 2_000_000_000, + heapUsed: 1_800_000_000, + heapTotal: 2_048_000_000, + external: 50_000_000, + arrayBuffers: 10_000_000, + }, + v8HeapStats: { + heapSizeLimit: 4_096_000_000, + totalHeapSize: 2_048_000_000, + usedHeapSize: 1_800_000_000, + }, + }), +})); + +function createMockConfig(overrides: Partial> = {}) { + return { + getSessionId: vi.fn().mockReturnValue('test-session-id-12345678'), + getCliVersion: vi.fn().mockReturnValue('0.17.0'), + getGeminiClient: vi.fn().mockReturnValue({ + getChat: () => ({ + getHistoryLength: () => 500, + }), + }), + storage: { + getProjectDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + ...overrides, + } as unknown as Config; +} + +describe('MemoryDiagnosticsDumper', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('writes diagnostics JSON on first dump', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + const result = await dumper.dump('hard'); + + expect(result).toBeDefined(); + expect(result!.trigger).toBe('hard'); + expect(result!.filePath).toContain('/tmp/test-project/diagnostics/'); + expect(result!.filePath).toContain('memory-test-ses'); + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('diagnostics'), + { recursive: true }, + ); + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + + const writtenContent = JSON.parse( + vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + ); + expect(writtenContent.trigger).toBe('hard'); + expect(writtenContent.dumpNumber).toBe(1); + expect(writtenContent.memoryUsage.rss).toBe(2_000_000_000); + expect(writtenContent.session.historyEntries).toBe(500); + expect(writtenContent.suggestion).toContain('/compact'); + }); + + it('respects per-session cap of 3 dumps', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + // Bypass cooldown by mocking Date.now + let mockNow = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => { + mockNow += 60_000; + return mockNow; + }); + + const r1 = await dumper.dump('hard'); + const r2 = await dumper.dump('critical'); + const r3 = await dumper.dump('hard'); + const r4 = await dumper.dump('critical'); + + expect(r1).toBeDefined(); + expect(r2).toBeDefined(); + expect(r3).toBeDefined(); + expect(r4).toBeUndefined(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(3); + }); + + it('respects cooldown between dumps', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + const mockNow = 1000000; + vi.spyOn(Date, 'now').mockReturnValue(mockNow); + + const r1 = await dumper.dump('hard'); + const r2 = await dumper.dump('hard'); + + expect(r1).toBeDefined(); + expect(r2).toBeUndefined(); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + }); + + it('resets state on new session', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + let mockNow = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => { + mockNow += 60_000; + return mockNow; + }); + + await dumper.dump('hard'); + await dumper.dump('hard'); + await dumper.dump('hard'); + + // Cap reached + const r4 = await dumper.dump('hard'); + expect(r4).toBeUndefined(); + + // Reset + dumper.resetForNewSession(); + + const r5 = await dumper.dump('critical'); + expect(r5).toBeDefined(); + expect(r5!.trigger).toBe('critical'); + }); + + it('includes critical suggestion for critical pressure', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + await dumper.dump('critical'); + + const writtenContent = JSON.parse( + vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + ); + expect(writtenContent.suggestion).toContain('critically high'); + }); + + it('handles missing geminiClient gracefully', async () => { + const config = createMockConfig({ + getGeminiClient: vi.fn().mockReturnValue(null), + }); + const dumper = new MemoryDiagnosticsDumper(config); + + const result = await dumper.dump('hard'); + + expect(result).toBeDefined(); + const writtenContent = JSON.parse( + vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + ); + expect(writtenContent.session.available).toBe(false); + }); +}); diff --git a/packages/core/src/services/memoryDiagnosticsDumper.ts b/packages/core/src/services/memoryDiagnosticsDumper.ts new file mode 100644 index 00000000000..d6b1fe7b892 --- /dev/null +++ b/packages/core/src/services/memoryDiagnosticsDumper.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Writes a lightweight memory diagnostics JSON to disk when the memory + * pressure monitor detects hard or critical pressure. The file survives + * a subsequent OOM crash, giving maintainers actionable data from bug + * reports without requiring the user to manually run `/doctor memory`. + * + * Design: diagnostics JSON is written BEFORE any expensive operation + * (like heap snapshots) so it lands on disk even if the process crashes + * during the heavier step. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { collectMemoryDiagnostics } from '../utils/memoryDiagnostics.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { getErrorMessage } from '../utils/errors.js'; +import type { Config } from '../config/config.js'; + +const debugLogger = createDebugLogger('MEMORY_DUMP'); + +/** Maximum dumps per session to avoid disk flooding. */ +const MAX_DUMPS_PER_SESSION = 3; + +/** Minimum interval between dumps (ms). */ +const MIN_DUMP_INTERVAL_MS = 30_000; + +export interface MemoryDumpResult { + filePath: string; + trigger: string; +} + +export class MemoryDiagnosticsDumper { + private dumpCount = 0; + private lastDumpTime = 0; + + constructor(private readonly config: Config) {} + + /** + * Resets session-scoped state. Called when a new session starts. + */ + resetForNewSession(): void { + this.dumpCount = 0; + this.lastDumpTime = 0; + } + + /** + * Writes a diagnostics snapshot to disk if within per-session limits. + * Returns the file path on success, or undefined if skipped/failed. + */ + async dump( + trigger: 'hard' | 'critical', + ): Promise { + if (this.dumpCount >= MAX_DUMPS_PER_SESSION) { + debugLogger.debug( + `Skipping dump: session cap reached (${MAX_DUMPS_PER_SESSION})`, + ); + return undefined; + } + + const now = Date.now(); + if (now - this.lastDumpTime < MIN_DUMP_INTERVAL_MS) { + debugLogger.debug('Skipping dump: cooldown not elapsed'); + return undefined; + } + + try { + const diagnosticsDir = this.ensureDiagnosticsDir(); + const sessionId = this.config.getSessionId(); + const timestamp = new Date() + .toISOString() + .replace(/:/g, '-') + .replace(/\./g, '_'); + const fileName = `memory-${sessionId.slice(0, 8)}-${timestamp}.json`; + const filePath = path.join(diagnosticsDir, fileName); + + const diagnostics = await collectMemoryDiagnostics({ + sessionId, + qwenVersion: this.config.getCliVersion(), + }); + + const payload = { + trigger, + dumpNumber: this.dumpCount + 1, + ...diagnostics, + session: this.collectSessionStats(), + suggestion: this.getSuggestion(trigger), + }; + + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); + + this.dumpCount++; + this.lastDumpTime = now; + + debugLogger.info( + `Memory diagnostics written to ${filePath} (trigger=${trigger}, dump #${this.dumpCount})`, + ); + + return { filePath, trigger }; + } catch (err) { + debugLogger.error( + `Failed to write memory diagnostics: ${getErrorMessage(err)}`, + ); + return undefined; + } + } + + private ensureDiagnosticsDir(): string { + const projectDir = this.config.storage.getProjectDir(); + const diagnosticsDir = path.join(projectDir, 'diagnostics'); + fs.mkdirSync(diagnosticsDir, { recursive: true }); + return diagnosticsDir; + } + + private collectSessionStats(): Record { + try { + const geminiClient = this.config.getGeminiClient?.(); + if (!geminiClient) return { available: false }; + const historyLength = geminiClient.getChat?.()?.getHistoryLength?.() ?? 0; + return { + historyEntries: historyLength, + }; + } catch { + return { available: false }; + } + } + + private getSuggestion(trigger: 'hard' | 'critical'): string { + if (trigger === 'critical') { + return 'Memory is critically high. Consider running /compact or starting a fresh session to avoid OOM.'; + } + return 'Memory pressure detected. Running /compact may help reduce memory usage.'; + } +} diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index 956755a0308..788fbdea522 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -11,6 +11,7 @@ import { getHeapStatistics } from 'node:v8'; import { createDebugLogger } from '../utils/debugLogger.js'; import { getErrorMessage } from '../utils/errors.js'; import type { Config } from '../config/config.js'; +import { MemoryDiagnosticsDumper } from './memoryDiagnosticsDumper.js'; // Types @@ -104,6 +105,7 @@ export class MemoryPressureMonitor extends EventEmitter { private consecutiveIneffectiveAggressiveCleanups = 0; private cleanupGeneration = 0; private readonly effectiveMemoryLimit: number; + private readonly diagnosticsDumper: MemoryDiagnosticsDumper; constructor(coreConfig: Config, pressureConfig?: MemoryPressureConfig) { super(); @@ -111,6 +113,7 @@ export class MemoryPressureMonitor extends EventEmitter { this.config = { ...(pressureConfig ?? DEFAULT_PRESSURE_CONFIG) }; validateMemoryPressureConfig(this.config); this.effectiveMemoryLimit = this.computeEffectiveMemoryLimit(); + this.diagnosticsDumper = new MemoryDiagnosticsDumper(coreConfig); const heapSizeLimit = getHeapStatistics().heap_size_limit; debugLogger.info( `Effective memory limit: ${formatMiB(this.effectiveMemoryLimit)} MiB; ` + @@ -142,6 +145,7 @@ export class MemoryPressureMonitor extends EventEmitter { resetForNewSession(): void { this.cleanupGeneration++; this.resetConsecutiveFailures(); + this.diagnosticsDumper.resetForNewSession(); this.cleanupInProgress = false; this.activeCleanupAction = 'none'; this.queuedCleanupRecommendation = undefined; @@ -196,6 +200,12 @@ export class MemoryPressureMonitor extends EventEmitter { return; } + // Write diagnostics to disk before cleanup — the JSON is cheap and survives + // even if a subsequent heap snapshot or cleanup triggers OOM. + if (pressure === 'hard' || pressure === 'critical') { + void this.diagnosticsDumper.dump(pressure); + } + this.executeCleanup(recommendation); } From 4aa1584c65877131f774773f7059f6572dcc6217 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 31 May 2026 18:13:44 +0800 Subject: [PATCH 2/5] ci: retrigger CI after Windows flaky failure From 8f776f7c7cca6a7c440198efed24d85e01d9995a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 1 Jun 2026 13:41:23 +0800 Subject: [PATCH 3/5] test(core): use path.join in memoryDiagnosticsDumper test for cross-platform The assertion hard-coded POSIX separators ('/tmp/test-project/diagnostics/'), which fails on Windows where path.join produces backslashes. Build the expected substring with path.join + path.sep so it matches the dumper's actual output on every platform. --- packages/core/src/services/memoryDiagnosticsDumper.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/memoryDiagnosticsDumper.test.ts b/packages/core/src/services/memoryDiagnosticsDumper.test.ts index 4019b1ec6ed..0fc223bf4a2 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.test.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { MemoryDiagnosticsDumper } from './memoryDiagnosticsDumper.js'; import type { Config } from '../config/config.js'; @@ -65,7 +66,9 @@ describe('MemoryDiagnosticsDumper', () => { expect(result).toBeDefined(); expect(result!.trigger).toBe('hard'); - expect(result!.filePath).toContain('/tmp/test-project/diagnostics/'); + expect(result!.filePath).toContain( + path.join('/tmp/test-project', 'diagnostics') + path.sep, + ); expect(result!.filePath).toContain('memory-test-ses'); expect(fs.mkdirSync).toHaveBeenCalledWith( expect.stringContaining('diagnostics'), From 33de27e616b9441244f9e7e4d1d6006f4ae0eaa8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 1 Jun 2026 16:36:00 +0800 Subject: [PATCH 4/5] fix(core): two-phase memory diagnostics write to survive OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critical issues from review: 1. The async collectMemoryDiagnostics() runs before writeFileSync, but it spawns a `ps` subprocess and reads /proc — fork() under critical memory pressure can fail or be OOM-killed, leaving no file on disk despite the "cheap write first" design comment. 2. dumpCount and lastDumpTime were updated after the await, so concurrent dumps (e.g. hard→critical escalation) would both pass the cap/cooldown guards and overwrite each other. Fix: - Reserve the dump slot synchronously (++dumpCount, lastDumpTime) before any await, so concurrent calls correctly hit the cap. - Phase 1: synchronously write a minimal JSON (process.memoryUsage + v8.getHeapStatistics, no fork/exec) with collectionComplete=false. Because async functions execute synchronously up to the first await, this is guaranteed on disk before the caller's next statement runs. - Phase 2: enrich with full diagnostics asynchronously and overwrite the file with collectionComplete=true. If Phase 2 crashes, the minimal Phase 1 file still survives for debugging. Tests updated for the two-phase write and gain two new cases covering the sync-Phase-1 guarantee and the synchronous slot reservation. --- .../services/memoryDiagnosticsDumper.test.ts | 93 ++++++++++++++++--- .../src/services/memoryDiagnosticsDumper.ts | 54 +++++++++-- .../src/services/memoryPressureMonitor.ts | 8 +- 3 files changed, 133 insertions(+), 22 deletions(-) diff --git a/packages/core/src/services/memoryDiagnosticsDumper.test.ts b/packages/core/src/services/memoryDiagnosticsDumper.test.ts index 0fc223bf4a2..c24d17c5f6d 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.test.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.test.ts @@ -15,6 +15,15 @@ vi.mock('node:fs', () => ({ writeFileSync: vi.fn(), })); +vi.mock('node:v8', () => ({ + getHeapStatistics: vi.fn().mockReturnValue({ + heap_size_limit: 4_096_000_000, + total_heap_size: 2_048_000_000, + used_heap_size: 1_800_000_000, + total_available_size: 2_000_000_000, + }), +})); + vi.mock('../utils/memoryDiagnostics.js', () => ({ collectMemoryDiagnostics: vi.fn().mockResolvedValue({ timestamp: '2026-05-31T00:00:00.000Z', @@ -74,16 +83,27 @@ describe('MemoryDiagnosticsDumper', () => { expect.stringContaining('diagnostics'), { recursive: true }, ); - expect(fs.writeFileSync).toHaveBeenCalledOnce(); + // Two-phase write: Phase 1 (minimal) + Phase 2 (full) + expect(fs.writeFileSync).toHaveBeenCalledTimes(2); - const writtenContent = JSON.parse( + const phase1Content = JSON.parse( vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, ); - expect(writtenContent.trigger).toBe('hard'); - expect(writtenContent.dumpNumber).toBe(1); - expect(writtenContent.memoryUsage.rss).toBe(2_000_000_000); - expect(writtenContent.session.historyEntries).toBe(500); - expect(writtenContent.suggestion).toContain('/compact'); + expect(phase1Content.trigger).toBe('hard'); + expect(phase1Content.dumpNumber).toBe(1); + expect(phase1Content.collectionComplete).toBe(false); + expect(phase1Content.memoryUsage).toBeDefined(); + expect(phase1Content.v8HeapStats).toBeDefined(); + + const phase2Content = JSON.parse( + vi.mocked(fs.writeFileSync).mock.calls[1][1] as string, + ); + expect(phase2Content.trigger).toBe('hard'); + expect(phase2Content.dumpNumber).toBe(1); + expect(phase2Content.collectionComplete).toBe(true); + expect(phase2Content.memoryUsage.rss).toBe(2_000_000_000); + expect(phase2Content.session.historyEntries).toBe(500); + expect(phase2Content.suggestion).toContain('/compact'); }); it('respects per-session cap of 3 dumps', async () => { @@ -106,7 +126,8 @@ describe('MemoryDiagnosticsDumper', () => { expect(r2).toBeDefined(); expect(r3).toBeDefined(); expect(r4).toBeUndefined(); - expect(fs.writeFileSync).toHaveBeenCalledTimes(3); + // 3 successful dumps × 2 writes each (Phase 1 + Phase 2) + expect(fs.writeFileSync).toHaveBeenCalledTimes(6); }); it('respects cooldown between dumps', async () => { @@ -121,7 +142,8 @@ describe('MemoryDiagnosticsDumper', () => { expect(r1).toBeDefined(); expect(r2).toBeUndefined(); - expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + // 1 successful dump × 2 writes (Phase 1 + Phase 2) + expect(fs.writeFileSync).toHaveBeenCalledTimes(2); }); it('resets state on new session', async () => { @@ -156,10 +178,58 @@ describe('MemoryDiagnosticsDumper', () => { await dumper.dump('critical'); + // Phase 2 (full payload) is the second write const writtenContent = JSON.parse( - vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + vi.mocked(fs.writeFileSync).mock.calls[1][1] as string, ); expect(writtenContent.suggestion).toContain('critically high'); + expect(writtenContent.collectionComplete).toBe(true); + }); + + it('writes Phase 1 synchronously before any await (survives crash during Phase 2)', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + // Fire dump() but do not await — Phase 1 must have already written to disk + // because async functions execute synchronously up to the first await. + const promise = dumper.dump('hard'); + + // At this point Phase 2 has not run yet (its await is pending), but Phase 1 + // must have completed its writeFileSync call. + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const phase1Content = JSON.parse( + vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + ); + expect(phase1Content.collectionComplete).toBe(false); + + await promise; + // Phase 2 has now overwritten the file + expect(fs.writeFileSync).toHaveBeenCalledTimes(2); + }); + + it('reserves slot synchronously to prevent concurrent dumps from bypassing cap', async () => { + const config = createMockConfig(); + const dumper = new MemoryDiagnosticsDumper(config); + + let mockNow = 1000000; + vi.spyOn(Date, 'now').mockImplementation(() => { + mockNow += 60_000; + return mockNow; + }); + + // Fire 4 concurrent dumps without awaiting between them. The synchronous + // slot reservation must enforce the cap of 3 even though all 4 calls happen + // before any of them complete their async Phase 2. + const results = await Promise.all([ + dumper.dump('hard'), + dumper.dump('hard'), + dumper.dump('hard'), + dumper.dump('hard'), + ]); + + const successful = results.filter((r) => r !== undefined); + expect(successful).toHaveLength(3); + expect(results[3]).toBeUndefined(); }); it('handles missing geminiClient gracefully', async () => { @@ -171,8 +241,9 @@ describe('MemoryDiagnosticsDumper', () => { const result = await dumper.dump('hard'); expect(result).toBeDefined(); + // Phase 2 (full payload) is the second write const writtenContent = JSON.parse( - vi.mocked(fs.writeFileSync).mock.calls[0][1] as string, + vi.mocked(fs.writeFileSync).mock.calls[1][1] as string, ); expect(writtenContent.session.available).toBe(false); }); diff --git a/packages/core/src/services/memoryDiagnosticsDumper.ts b/packages/core/src/services/memoryDiagnosticsDumper.ts index d6b1fe7b892..53fb845e04e 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.ts @@ -17,6 +17,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as v8 from 'node:v8'; import { collectMemoryDiagnostics } from '../utils/memoryDiagnostics.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -51,7 +52,17 @@ export class MemoryDiagnosticsDumper { /** * Writes a diagnostics snapshot to disk if within per-session limits. - * Returns the file path on success, or undefined if skipped/failed. + * + * Uses a two-phase write strategy: + * - Phase 1 (synchronous): writes a minimal JSON with process.memoryUsage() + * and v8.getHeapStatistics() — no fork/exec, so it lands on disk even + * under extreme memory pressure. + * - Phase 2 (async): collects full diagnostics (may spawn subprocesses) + * and overwrites the file with the complete payload. If Phase 2 crashes, + * Phase 1's file still survives for debugging. + * + * Slot is reserved synchronously before any await to prevent concurrent + * invocations from bypassing the cap/cooldown guards. */ async dump( trigger: 'hard' | 'critical', @@ -69,6 +80,10 @@ export class MemoryDiagnosticsDumper { return undefined; } + // Reserve slot synchronously to prevent race between concurrent dumps + const dumpNumber = ++this.dumpCount; + this.lastDumpTime = now; + try { const diagnosticsDir = this.ensureDiagnosticsDir(); const sessionId = this.config.getSessionId(); @@ -79,30 +94,51 @@ export class MemoryDiagnosticsDumper { const fileName = `memory-${sessionId.slice(0, 8)}-${timestamp}.json`; const filePath = path.join(diagnosticsDir, fileName); + // Phase 1: synchronous minimal write — survives crash during Phase 2 + const minimalPayload = { + trigger, + dumpNumber, + timestamp: new Date().toISOString(), + memoryUsage: process.memoryUsage(), + v8HeapStats: v8.getHeapStatistics(), + session: this.collectSessionStats(), + suggestion: this.getSuggestion(trigger), + collectionComplete: false, + }; + fs.writeFileSync( + filePath, + JSON.stringify(minimalPayload, null, 2), + 'utf8', + ); + + debugLogger.info( + `Phase 1 diagnostics written to ${filePath} (trigger=${trigger}, dump #${dumpNumber})`, + ); + + // Phase 2: full collection (may fork subprocesses — risky under pressure) const diagnostics = await collectMemoryDiagnostics({ sessionId, qwenVersion: this.config.getCliVersion(), }); - const payload = { + const fullPayload = { trigger, - dumpNumber: this.dumpCount + 1, + dumpNumber, ...diagnostics, session: this.collectSessionStats(), suggestion: this.getSuggestion(trigger), + collectionComplete: true, }; - - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); - - this.dumpCount++; - this.lastDumpTime = now; + fs.writeFileSync(filePath, JSON.stringify(fullPayload, null, 2), 'utf8'); debugLogger.info( - `Memory diagnostics written to ${filePath} (trigger=${trigger}, dump #${this.dumpCount})`, + `Phase 2 diagnostics written to ${filePath} (trigger=${trigger}, dump #${dumpNumber})`, ); return { filePath, trigger }; } catch (err) { + // Slot stays consumed — a failed write should not open the door to more + // attempts that would likely also fail under the same pressure conditions. debugLogger.error( `Failed to write memory diagnostics: ${getErrorMessage(err)}`, ); diff --git a/packages/core/src/services/memoryPressureMonitor.ts b/packages/core/src/services/memoryPressureMonitor.ts index 788fbdea522..babecda4d4f 100644 --- a/packages/core/src/services/memoryPressureMonitor.ts +++ b/packages/core/src/services/memoryPressureMonitor.ts @@ -200,8 +200,12 @@ export class MemoryPressureMonitor extends EventEmitter { return; } - // Write diagnostics to disk before cleanup — the JSON is cheap and survives - // even if a subsequent heap snapshot or cleanup triggers OOM. + // Write diagnostics to disk before cleanup. dump() uses a two-phase strategy: + // Phase 1 (synchronous, before the first await) writes a minimal JSON via + // writeFileSync — guaranteed to complete before executeCleanup starts because + // async functions run synchronously up to the first await. Phase 2 enriches + // the file asynchronously in the background; if it fails the minimal file + // still survives for debugging. if (pressure === 'hard' || pressure === 'critical') { void this.diagnosticsDumper.dump(pressure); } From b3a6fbaa5c279f9023405c43301aaef34a585073 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 1 Jun 2026 17:01:53 +0800 Subject: [PATCH 5/5] fix(core): point memory diagnostics suggestion at /compress (the actual command) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion text told users to run /compact, which does not exist in this repository — the actual command is /compress (see compressCommand.ts). Pointing users at a nonexistent slash command in a diagnostics report makes the suggestion unactionable. --- packages/core/src/services/memoryDiagnosticsDumper.test.ts | 2 +- packages/core/src/services/memoryDiagnosticsDumper.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/memoryDiagnosticsDumper.test.ts b/packages/core/src/services/memoryDiagnosticsDumper.test.ts index c24d17c5f6d..a5755ba227e 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.test.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.test.ts @@ -103,7 +103,7 @@ describe('MemoryDiagnosticsDumper', () => { expect(phase2Content.collectionComplete).toBe(true); expect(phase2Content.memoryUsage.rss).toBe(2_000_000_000); expect(phase2Content.session.historyEntries).toBe(500); - expect(phase2Content.suggestion).toContain('/compact'); + expect(phase2Content.suggestion).toContain('/compress'); }); it('respects per-session cap of 3 dumps', async () => { diff --git a/packages/core/src/services/memoryDiagnosticsDumper.ts b/packages/core/src/services/memoryDiagnosticsDumper.ts index 53fb845e04e..81fb51f54f3 100644 --- a/packages/core/src/services/memoryDiagnosticsDumper.ts +++ b/packages/core/src/services/memoryDiagnosticsDumper.ts @@ -168,8 +168,8 @@ export class MemoryDiagnosticsDumper { private getSuggestion(trigger: 'hard' | 'critical'): string { if (trigger === 'critical') { - return 'Memory is critically high. Consider running /compact or starting a fresh session to avoid OOM.'; + return 'Memory is critically high. Consider running /compress or starting a fresh session to avoid OOM.'; } - return 'Memory pressure detected. Running /compact may help reduce memory usage.'; + return 'Memory pressure detected. Running /compress may help reduce memory usage.'; } }