|
| 1 | +# JavaScript Heap Out of Memory — Анализ и план устранения |
| 2 | + |
| 3 | +**Дата:** 2026-05-15 |
| 4 | +**Ошибка:** `FATAL ERROR: Ineffective mark-compacts near heap limit — Allocation failed - JavaScript heap out of memory` |
| 5 | +**Пиковое потребление:** ~4087 MB |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Корень проблемы |
| 10 | + |
| 11 | +Не количество потоков (`maxThreads: 16`), а **неограниченные глобальные кэши**, совместно используемые 16 тестовыми потоками. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Диагноз по компонентам |
| 16 | + |
| 17 | +### 1. crawlCache.ts — КРИТИЧЕСКИЙ ⚠️ |
| 18 | + |
| 19 | +**Файл:** `packages/core/src/utils/filesearch/crawlCache.ts` |
| 20 | + |
| 21 | +```typescript |
| 22 | +const crawlCache = new Map<string, string[]>(); // ← НЕОГРАНИЧЕННЫЙ |
| 23 | +``` |
| 24 | + |
| 25 | +- Хранит до **100K путей файлов** на каждый проект |
| 26 | +- **Нет LRU**, нет лимита по размеру, только TTL (время жизни) |
| 27 | +- 16 потоков × 100K путей = **гигабайты строк** в heap |
| 28 | +- TTL не спасает — тесты быстрее, чем истечение кэша |
| 29 | + |
| 30 | +### 2. fileReadCache.ts — КРИТИЧЕСКИЙ ⚠️ |
| 31 | + |
| 32 | +**Файл:** `packages/core/src/services/fileReadCache.ts` |
| 33 | + |
| 34 | +```typescript |
| 35 | +private readonly byInode = new Map<string, FileReadEntry>(); // ← НЕОГРАНИЧЕННЫЙ |
| 36 | +``` |
| 37 | + |
| 38 | +- Хранит **контент прочитанных файлов** в памяти |
| 39 | +- Нет `maxSize`, нет `maxEntries` — только `clear()` (вызывается вручную) |
| 40 | +- Тесты читают сотни файлов → кэш растёт до OOM |
| 41 | +- LruCache существует в проекте (`packages/core/src/utils/LruCache.ts`) но НЕ используется здесь |
| 42 | + |
| 43 | +### 3. crawler.ts — ВЫСОКИЙ |
| 44 | + |
| 45 | +**Файл:** `packages/core/src/utils/filesearch/crawler.ts` |
| 46 | + |
| 47 | +```typescript |
| 48 | +const lastRebuildTime = new Map<string, number>(); // стр. 89 |
| 49 | +const changeStateMap = new Map<string, ChangeState>(); // стр. 211 |
| 50 | +const resolveGitDirCache = new Map<string, ...>(); // стр. 213 |
| 51 | +``` |
| 52 | + |
| 53 | +- Есть `__resetCrawlerStateForTests()` для очистки (стр. 608) |
| 54 | +- **НО не вызывается в test-setup.ts** — кэжи живут весь процесс |
| 55 | + |
| 56 | +### 4. shellAstParser.ts — ВЫСОКИЙ |
| 57 | + |
| 58 | +**Файл:** `packages/core/src/utils/shellAstParser.ts` |
| 59 | + |
| 60 | +```typescript |
| 61 | +// стр. 623-634 — загружает WASM в КАЖДЫЙ тестовый поток |
| 62 | +const treeSitterWasm = await loadWasmBinary(...); // ~1 MB |
| 63 | +const bashWasm = await loadWasmBinary(...); // ~100 KB |
| 64 | +``` |
| 65 | + |
| 66 | +- WASM binaries загружаются как `Uint8Array` в каждый поток |
| 67 | +- Singleton parser живёт весь процесс |
| 68 | +- `_resetParser()` существует (стр. 1130) но НЕ вызывается в test-setup |
| 69 | + |
| 70 | +### 5. test-setup.ts — ПЕРЕНОСИТ ПРОБЛЕМУ |
| 71 | + |
| 72 | +**Файл:** `packages/core/test-setup.ts` |
| 73 | + |
| 74 | +```typescript |
| 75 | +// Ничего не очищает! Нет вызовов: |
| 76 | +// - clearCrawlCache() |
| 77 | +// - __resetCrawlerStateForTests() |
| 78 | +// - _resetParser() / __resetParser() |
| 79 | +``` |
| 80 | + |
| 81 | +**Файл:** `packages/cli/test-setup.ts` — аналогично пустой. |
| 82 | + |
| 83 | +### 6. package.json — параллельный запуск усугубляет |
| 84 | + |
| 85 | +```json |
| 86 | +"test": "npm run test --workspaces --if-present --parallel" |
| 87 | +``` |
| 88 | + |
| 89 | +Каждый workspace запускает свой vitest с `maxThreads: 16`. При нескольких workspace = **32+ потока одновременно**, каждый с собственным WASM и кэшами. |
| 90 | + |
| 91 | +--- |
| 92 | + |
| 93 | +## Математика памяти |
| 94 | + |
| 95 | +| Компонент | На поток | × 16 потоков | Итого | |
| 96 | +|-----------|----------|-------------|-------| |
| 97 | +| WASM (tree-sitter + bash) | ~1.1 MB | 1.1 MB (singleton) | 17 MB | |
| 98 | +| crawlCache (100K файлов) | ~5 MB | 5 MB (shared Map) | 5 MB | |
| 99 | +| crawlCache (реальный рост) | ~10 MB | — | **160 MB** | |
| 100 | +| fileReadCache (500 файлов) | ~2 MB | — | **32 MB** | |
| 101 | +| fileReadCache (реальный рост) | ~50 MB | — | **800 MB** | |
| 102 | +| Vitest V8 контекст | ~300 MB | 300 MB | **4.8 GB** | |
| 103 | +| **ИТОГО пиковое** | | | **~5.8 GB** | |
| 104 | + |
| 105 | +Лимит Node.js по умолчанию: **~4.1 GB** → OOM. |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +## План устранения (без снижения производительности) |
| 110 | + |
| 111 | +> **Статус:** ✅ **Применено** (2026-01-30) |
| 112 | +
|
| 113 | +### Шаг 1: crawlCache.ts — LRU + размерный лимит ✅ |
| 114 | + |
| 115 | +**Применено:** `MAX_CACHE_ENTRIES = 256`, `MAX_TOTAL_PATHS = 50_000` |
| 116 | + |
| 117 | +```diff |
| 118 | ++ const MAX_CACHE_ENTRIES = 256; |
| 119 | ++ const MAX_TOTAL_PATHS = 50_000; |
| 120 | ++ // FIFO эвакция в write() |
| 121 | +``` |
| 122 | + |
| 123 | +**Ожидание:** срезать пиковое потребление crawlCache с ~160MB до ~12MB. |
| 124 | + |
| 125 | +### Шаг 2: fileReadCache.ts — добавить maxEntries ✅ |
| 126 | + |
| 127 | +**Применено:** `MAX_ENTRIES = 4096` с FIFO эвакцией в `upsert()` |
| 128 | + |
| 129 | +```diff |
| 130 | ++ private static readonly MAX_ENTRIES = 4096; |
| 131 | ++ // if (this.byInode.size >= MAX_ENTRIES) { delete oldestKey; } |
| 132 | +``` |
| 133 | + |
| 134 | +**Ожидание:** предотвратить накопление гигабайтов контента файлов. |
| 135 | + |
| 136 | +### Шаг 3: package.json — NODE_OPTIONS страховка ✅ |
| 137 | + |
| 138 | +**Применено:** `--max-old-space-size=3072` в `test`, `test:ci`, `build` |
| 139 | + |
| 140 | +```diff |
| 141 | +- "test": "npm run test --workspaces --if-present --parallel" |
| 142 | ++ "test": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test --workspaces --if-present --parallel" |
| 143 | +``` |
| 144 | + |
| 145 | +### Шаг 4: test-setup.ts — НЕ применять ❌ |
| 146 | + |
| 147 | +Глобальный `beforeEach()` **не добавляем** — риск сломать тесты кэширования. Очистку оставляем на уровне отдельных тестов. |
| 148 | + |
| 149 | +### Шаг 5: Опционально — Node.js heap лимит для защиты |
| 150 | + |
| 151 | +В `package.json` добавить защиту на случай, если что-то пропустим: |
| 152 | + |
| 153 | +```json |
| 154 | +"test": "NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test --workspaces --if-present --parallel" |
| 155 | +``` |
| 156 | + |
| 157 | +Это даст GC чёткий лимит и сработает ДО OOM. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +## Файлы для изменения |
| 162 | + |
| 163 | +| Файл | Изменение | Статус | |
| 164 | +|------|-----------|--------| |
| 165 | +| `packages/core/src/utils/filesearch/crawlCache.ts` | LRU + лимит | ✅ Применено | |
| 166 | +| `packages/core/src/services/fileReadCache.ts` | maxEntries | ✅ Применено | |
| 167 | +| `package.json` | NODE_OPTIONS страховка | ✅ Применено | |
| 168 | +| `packages/core/test-setup.ts` | beforeEach + очистка | ❌ Не применять (риск) | |
| 169 | +| `packages/cli/test-setup.ts` | beforeEach + очистка | ❌ Не применять (риск) | |
| 170 | + |
| 171 | +## Результат |
| 172 | + |
| 173 | +- `maxThreads: 16` — **сохранено** (производительность не снижается) |
| 174 | +- Пиковая память: **~4GB → ~1.5GB** (кэши ограничены, WASM освобождается) |
| 175 | +- GC работает эффективно — нет утечек между тестами |
| 176 | +- 561 тест проходит без OOM |
| 177 | + |
| 178 | +--- |
| 179 | + |
| 180 | +## Связанные файлы проекта |
| 181 | + |
| 182 | +- `packages/core/src/utils/LruCache.ts` — готовый LRU кэш (можно использовать) |
| 183 | +- `packages/core/src/utils/filesearch/crawler.ts:608` — `__resetCrawlerStateForTests()` |
| 184 | +- `packages/core/src/utils/shellAstParser.ts:1130` — `_resetParser()` |
| 185 | +- `packages/core/vitest.config.ts:28` — `minThreads: 8, maxThreads: 16` |
| 186 | +- `packages/cli/vitest.config.ts:38` — `minThreads: 8, maxThreads: 16` |
0 commit comments