Skip to content

Commit 5be2c95

Browse files
committed
feat(core): add memory pressure monitor
1 parent ce82d65 commit 5be2c95

9 files changed

Lines changed: 1577 additions & 0 deletions
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
---
2+
title: 'Memory Pressure Monitor'
3+
date: '2026-05-21'
4+
status: 'implemented'
5+
---
6+
7+
# Memory Pressure Monitor
8+
9+
## Problem
10+
11+
Long-running Qwen Code sessions can accumulate memory through large tool
12+
results, repeated file reads, chat history, and native/external allocations.
13+
Before this change, the core package had diagnostics and session-reset cleanup,
14+
but no runtime response when memory pressure rises during normal tool
15+
execution.
16+
17+
The highest-value cache-specific gap is `FileReadCache`: it already has a
18+
bounded FIFO size, but it did not have a time-based eviction path. That means a
19+
session can retain inactive file-read metadata until the hard entry limit is
20+
hit, even when the process is under memory pressure.
21+
22+
## Goals
23+
24+
- Add a low-overhead memory pressure check after tool execution.
25+
- Prefer surgical cleanup before destructive cleanup.
26+
- Respect container memory limits when cgroup v2 or cgroup v1 memory limit
27+
files are available.
28+
- React to V8 heap pressure before JavaScript heap OOM on high-memory hosts.
29+
- Keep subagent/scoped `Config` instances isolated from parent session cleanup.
30+
- Make behavior configurable through environment variables without adding a new
31+
user-facing settings surface.
32+
33+
## Non-Goals
34+
35+
- Do not add a background polling loop.
36+
- Do not make explicit GC the default; it only runs when enabled and Node was
37+
started with `--expose-gc`.
38+
- Do not change prior-read enforcement semantics. Cache eviction can remove old
39+
metadata, but it must not weaken stale-file checks for retained entries.
40+
41+
## Design
42+
43+
`Config.initialize()` creates one `MemoryPressureMonitor` per initialized
44+
`Config`. `getMemoryPressureMonitor()` mirrors the existing `getFileReadCache()`
45+
Object.create isolation pattern: when a child config is created through
46+
prototype delegation, the getter lazily installs an own monitor bound to that
47+
child config.
48+
49+
`CoreToolScheduler.executeSingleToolCall()` calls `scheduleCheck()` in its
50+
`finally` block after ending the tool span. `scheduleCheck()` coalesces multiple
51+
calls in the same event-loop turn with `queueMicrotask`, so concurrent read-like
52+
tool batches do not run one memory check per tool result.
53+
54+
The monitor uses the stronger of two pressure signals:
55+
56+
- RSS divided by an effective process memory limit. Prefer cgroup v2
57+
`/sys/fs/cgroup/memory.max` when it is a finite positive value; fall back to
58+
cgroup v1 `/sys/fs/cgroup/memory/memory.limit_in_bytes`, then to
59+
`os.totalmem()` otherwise. cgroup v1's huge "unlimited" sentinel values are
60+
ignored.
61+
- V8 `heapUsed` divided by `getHeapStatistics().heap_size_limit`.
62+
63+
Using both signals matters because containers usually fail by RSS/cgroup limit,
64+
while local high-memory machines can hit V8 heap OOM long before RSS is a large
65+
fraction of total system memory.
66+
67+
Default thresholds are intentionally conservative enough to react before the OS
68+
or container OOM killer does:
69+
70+
- `softPressureRatio = 0.50`
71+
- `hardPressureRatio = 0.65`
72+
- `criticalRatio = 0.80`
73+
- `cleanupCooldownMs = 5000`
74+
- `enableExplicitGC = false`
75+
76+
Environment overrides:
77+
78+
- `QWEN_MEMORY_PRESSURE_SOFT`
79+
- `QWEN_MEMORY_PRESSURE_HARD`
80+
- `QWEN_MEMORY_PRESSURE_CRITICAL`
81+
- `QWEN_MEMORY_ENABLE_GC=1`
82+
83+
Invalid ratios fall back to defaults. Valid ratios must be ordered as
84+
`soft < hard < critical`, with a lower soft bound of `0.3` and an upper
85+
critical bound of `0.98`. Ratio env vars are parsed strictly with `Number()`,
86+
so values such as `0.8extra` are rejected instead of partially accepted.
87+
Invalid memory-pressure env configuration writes a visible warning to stderr
88+
and to the debug log before falling back to defaults.
89+
90+
## Cleanup Policy
91+
92+
Pressure levels map to increasingly strong cleanup:
93+
94+
- `soft`: evict stale `FileReadCache` entries not accessed in 60 minutes.
95+
- `hard`: evict cache entries not accessed in 30 minutes.
96+
- `critical`: clear the file-read cache and optionally trigger `global.gc()`.
97+
98+
The monitor intentionally does not force chat compaction. Compaction can call
99+
the model backend and rewrite active chat state, so it should be triggered only
100+
from a call site that can safely coordinate with the conversation loop.
101+
102+
Cleanup is fire-and-forget from the scheduler, but the monitor guards cleanup
103+
steps with `cleanupInProgress` and a cooldown timestamp. A higher-pressure
104+
cleanup can bypass the cooldown and queue behind an in-progress lower-pressure
105+
cleanup, so a `critical` check is not lost while a `soft` cleanup is finishing.
106+
After successful cleanup it logs an RSS delta on `setImmediate()`, but RSS
107+
movement is diagnostic only: V8 and libc may retain freed pages even when
108+
JavaScript objects became collectible. Consecutive failures count cleanup-step
109+
exceptions, not unchanged RSS, and the counter is reset on a new session. If
110+
three successful cleanup attempts in a row free less than 1% RSS, the monitor
111+
emits `memory-cleanup-ineffective` as a diagnostic signal without treating the
112+
cleanup step itself as failed.
113+
114+
## Test Coverage
115+
116+
The implementation is covered by:
117+
118+
- threshold validation tests;
119+
- environment config parsing, fallback, visible warning, and explicit GC tests;
120+
- pressure classification tests using mocked `process.memoryUsage()`;
121+
- cgroup v2 `memory.max` and cgroup v1 `memory.limit_in_bytes` behavior;
122+
- V8 heap limit behavior;
123+
- `scheduleCheck()` coalescing;
124+
- scheduler integration that invokes `scheduleCheck()` after tool execution;
125+
- soft and critical cleanup actions;
126+
- cleanup failure accounting for thrown cleanup steps;
127+
- cleanup listener exception isolation and ineffective-cleanup diagnostics;
128+
- child `Config` monitor isolation through `Object.create`;
129+
- `FileReadCache.evictNotAccessedSince()` behavior.
130+
131+
## Risks And Tradeoffs
132+
133+
- RSS can stay flat after cleanup because V8 or libc may retain freed memory.
134+
RSS deltas are logged, but unchanged RSS does not count as a cleanup failure.
135+
- Time-based file-read cache eviction may reduce fast-path hits for old files,
136+
but it preserves recently active entries and only runs under memory pressure.

packages/core/src/config/config.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from './config.js';
1717
import { Storage } from './storage.js';
1818
import * as fs from 'node:fs';
19+
import * as os from 'node:os';
1920
import * as path from 'node:path';
2021
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../memory/const.js';
2122
import {
@@ -288,6 +289,13 @@ vi.mock('../ide/ide-client.js', () => ({
288289

289290
import { BaseLlmClient } from '../core/baseLlmClient.js';
290291

292+
const MEMORY_PRESSURE_ENV_KEYS = [
293+
'QWEN_MEMORY_PRESSURE_SOFT',
294+
'QWEN_MEMORY_PRESSURE_HARD',
295+
'QWEN_MEMORY_PRESSURE_CRITICAL',
296+
'QWEN_MEMORY_ENABLE_GC',
297+
];
298+
291299
vi.mock('../core/baseLlmClient.js');
292300
// Mock fireNotificationHook from toolHookTriggers
293301
vi.mock('../core/toolHookTriggers.js', () => ({
@@ -328,6 +336,9 @@ describe('Server Config (config.ts)', () => {
328336
beforeEach(() => {
329337
// Reset mocks if necessary
330338
vi.clearAllMocks();
339+
for (const envName of MEMORY_PRESSURE_ENV_KEYS) {
340+
delete process.env[envName];
341+
}
331342
(fs.existsSync as Mock).mockReturnValue(true);
332343
(fs.readdirSync as Mock).mockReturnValue([]);
333344
(fs.statSync as Mock).mockReturnValue({
@@ -422,6 +433,192 @@ describe('Server Config (config.ts)', () => {
422433
});
423434
});
424435

436+
describe('MemoryPressureMonitor isolation', () => {
437+
it('returns a distinct monitor for child Configs created via Object.create', async () => {
438+
const parent = new Config(baseParams);
439+
await parent.initialize({ skipGeminiInitialization: true });
440+
const child = Object.create(parent) as Config;
441+
442+
const parentMonitor = parent.getMemoryPressureMonitor();
443+
const childMonitor = child.getMemoryPressureMonitor();
444+
445+
expect(parentMonitor).toBeDefined();
446+
expect(childMonitor).toBeDefined();
447+
expect(childMonitor).not.toBe(parentMonitor);
448+
expect(child.getMemoryPressureMonitor()).toBe(childMonitor);
449+
});
450+
451+
it('resets monitor cleanup failures when starting a new session', async () => {
452+
const config = new Config(baseParams);
453+
await config.initialize({ skipGeminiInitialization: true });
454+
const monitor = config.getMemoryPressureMonitor();
455+
expect(monitor).toBeDefined();
456+
const resetSpy = vi.spyOn(monitor!, 'resetConsecutiveFailures');
457+
458+
config.startNewSession();
459+
460+
expect(resetSpy).toHaveBeenCalledTimes(1);
461+
});
462+
});
463+
464+
describe('MemoryPressure configuration environment', () => {
465+
const restorers: Array<() => void> = [];
466+
const originalEnv = new Map<string, string | undefined>();
467+
468+
beforeEach(() => {
469+
originalEnv.clear();
470+
for (const envName of MEMORY_PRESSURE_ENV_KEYS) {
471+
originalEnv.set(envName, process.env[envName]);
472+
delete process.env[envName];
473+
}
474+
});
475+
476+
afterEach(() => {
477+
while (restorers.length > 0) {
478+
restorers.pop()?.();
479+
}
480+
for (const [envName, value] of originalEnv) {
481+
if (value === undefined) {
482+
delete process.env[envName];
483+
} else {
484+
process.env[envName] = value;
485+
}
486+
}
487+
originalEnv.clear();
488+
});
489+
490+
function mockMemoryRatio(rssRatio: number, heapUsedBytes = 0): void {
491+
const spy = vi.spyOn(process, 'memoryUsage').mockReturnValue({
492+
rss: Math.ceil(os.totalmem() * rssRatio),
493+
heapTotal: 512 * 1024 * 1024,
494+
heapUsed: heapUsedBytes,
495+
external: 0,
496+
arrayBuffers: 0,
497+
});
498+
restorers.push(() => spy.mockRestore());
499+
}
500+
501+
function mockStderrWrite(): Mock {
502+
const spy = vi
503+
.spyOn(process.stderr, 'write')
504+
.mockImplementation(() => true);
505+
restorers.push(() => spy.mockRestore());
506+
return spy as unknown as Mock;
507+
}
508+
509+
it('applies valid memory pressure env overrides', async () => {
510+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.3';
511+
process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.6';
512+
process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9';
513+
514+
const config = new Config(baseParams);
515+
await config.initialize({ skipGeminiInitialization: true });
516+
mockMemoryRatio(0.35);
517+
518+
expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe(
519+
'soft',
520+
);
521+
});
522+
523+
it('falls back to defaults and warns on strict env parse failures', async () => {
524+
const stderrSpy = mockStderrWrite();
525+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.3extra';
526+
process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.6';
527+
process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9';
528+
529+
const config = new Config(baseParams);
530+
await config.initialize({ skipGeminiInitialization: true });
531+
mockMemoryRatio(0.35);
532+
533+
expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe(
534+
'normal',
535+
);
536+
expect(stderrSpy).toHaveBeenCalledWith(
537+
expect.stringContaining('Invalid memory pressure config'),
538+
);
539+
});
540+
541+
it('falls back to defaults and warns on invalid threshold ordering', async () => {
542+
const stderrSpy = mockStderrWrite();
543+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.7';
544+
545+
const config = new Config(baseParams);
546+
await config.initialize({ skipGeminiInitialization: true });
547+
548+
expect(config.getMemoryPressureMonitor()).toBeDefined();
549+
expect(stderrSpy).toHaveBeenCalledWith(
550+
expect.stringContaining(
551+
'softPressureRatio must be < hardPressureRatio',
552+
),
553+
);
554+
});
555+
556+
it.each(['NaN', 'Infinity', '0'])(
557+
'falls back to defaults for invalid soft threshold %s',
558+
async (value) => {
559+
const stderrSpy = mockStderrWrite();
560+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = value;
561+
562+
const config = new Config(baseParams);
563+
await config.initialize({ skipGeminiInitialization: true });
564+
mockMemoryRatio(0.35);
565+
566+
expect(config.getMemoryPressureMonitor()?.getPressureLevel()).toBe(
567+
'normal',
568+
);
569+
expect(stderrSpy).toHaveBeenCalledWith(
570+
expect.stringContaining('Invalid memory pressure config'),
571+
);
572+
},
573+
);
574+
575+
it('enables explicit GC when requested by env', async () => {
576+
process.env['QWEN_MEMORY_ENABLE_GC'] = '1';
577+
const globalWithGc = global as typeof global & { gc?: () => void };
578+
const originalGc = globalWithGc.gc;
579+
const gcSpy = vi.fn();
580+
Object.defineProperty(globalWithGc, 'gc', {
581+
value: gcSpy,
582+
configurable: true,
583+
});
584+
restorers.push(() => {
585+
if (originalGc) {
586+
Object.defineProperty(globalWithGc, 'gc', {
587+
value: originalGc,
588+
configurable: true,
589+
});
590+
} else {
591+
delete globalWithGc.gc;
592+
}
593+
});
594+
595+
const config = new Config(baseParams);
596+
await config.initialize({ skipGeminiInitialization: true });
597+
mockMemoryRatio(0.85);
598+
599+
config.getMemoryPressureMonitor()?.performCheck();
600+
await Promise.resolve();
601+
602+
expect(gcSpy).toHaveBeenCalledTimes(1);
603+
});
604+
605+
it('child Config monitors inherit the parent memory pressure config snapshot', async () => {
606+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.3';
607+
process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.6';
608+
process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.9';
609+
const parent = new Config(baseParams);
610+
await parent.initialize({ skipGeminiInitialization: true });
611+
612+
process.env['QWEN_MEMORY_PRESSURE_SOFT'] = '0.9';
613+
process.env['QWEN_MEMORY_PRESSURE_HARD'] = '0.95';
614+
process.env['QWEN_MEMORY_PRESSURE_CRITICAL'] = '0.97';
615+
const child = Object.create(parent) as Config;
616+
mockMemoryRatio(0.35);
617+
618+
expect(child.getMemoryPressureMonitor()?.getPressureLevel()).toBe('soft');
619+
});
620+
});
621+
425622
describe('startNewSession', () => {
426623
it('clears the FileReadCache so a new session does not inherit prior reads', () => {
427624
// Regression guard: the file-read cache backs ReadFile's

0 commit comments

Comments
 (0)