fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464) #465
Reference in New Issue
Block a user
Delete Branch "fix/464-diffdocs-cpu-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Боевой инцидент:
diffDocs(MCP-обёртка для превью правок) синхронно зовётrecreateTransform(rfc6902), чей array-diff — O(n·m) по числу нод и O(w²) по длине прогонов слов, и который никогда не бросает. Поэтому существующийcatch → coarseDiffне спасает event loop: одна большая агентская правка блокирует воркер на секунды→минуты (бенч: 401 нода → 1.3 с, 801 → 5.5 с, 1601 → OOM; отдельная ось — байт-тяжёлый вход: 309 КБ → OOM). Заблокированный воркер душит все остальные запросы — это и есть живой прод-хэнг.Фикс — pre-flight size-guard перед дорогим вызовом, целиком в
packages/mcp:exceedsDiffSizeGuard(old, new)срабатывает поmax(old, new)на обеих осях —countNodesиJSON.stringify().length— так что асимметричная пара (крошечный new / огромный old и наоборот) тоже триггерит. Дёшево: один обход + один stringify на документ.MCP_DIFF_MAX_NODES=150,MCP_DIFF_MAX_BYTES=12288(12 КиБ), env-переопределяемы. Подобраны бенчмарком под бюджет ~200 мс на любой форме входа на границе cap'а. Исходные догадки тикета (3000–5000 нод / 512 КБ–1 МБ) оказались в 20–30× завышены — пропускали бы многоминутные блоки, поэтому порог задал бенчмарк, а не они.readPositiveIntEnv: парс env при каждом вызове; мусор / пусто / ≤0 / не-finite → дефолт, так что guard нельзя случайно отключить переменной окружения.coarseDiffTally(единый источник формыfellBack:true) иpreciseDiffTally. Новый поток:computeIntegrity(без изменений, для обоих путей) → еслиexceedsDiffSizeGuard→coarseDiffTally(fellBack=true)→ иначеtry preciseDiffTally / catch → coarseDiffTally. Обе деградации возвращают идентичную форму.Fail-closed: единственный путь к
recreateTransform— веткаelse, достижимая только когда guard не сработал; огромный документ физически до transform не доходит.computeIntegrity(корректностный сигнал: счётчики нод/ссылок/сносок) считается всегда. Все три call-site потребляютDiffResultкак информационный preview и не гейтят запись — coarse-фоллбэк контракт-сохраняющий.closes #464
How verified
Прогонял в
packages/mcp/.../wt464(mcp-side, внешний симлинк не трогал):npx tsc --noEmit→ exit 0, чисто.node --test test/unit/*.test.mjs test/mock/*.test.mjs→ 688 pass, 0 fail (включая 11 новых).@docmost/mcp → ../../packages/mcpне тронут; серверные tsc/jest не запускались (изменение mcp-side).Checklist
diff.ts+ 2 файла тестов)Прод-инцидент, приоритетно.
Что сделал: pre-flight size-guard в
diffDocsперед синхроннымrecreateTransform, который O(n·m)/O(w²) и никогда не бросает — существующий catch его не ловит, воркер вставал на секунды→минуты. Порог (MCP_DIFF_MAX_NODES=150/MCP_DIFF_MAX_BYTES=12288) откалибровал бенчмарком под ~200 мс на любой форме входа; догадки тикета были в 20–30× завышены. Guard fail-closed (мусорный env не отключает), срабатывает поmax(old,new)на обеих осях,computeIntegrityсчитается всегда, coarse-фоллбэк контракт-сохраняющий.Проверка на стенде mcp-side:
tsc --noEmitчисто,node --test688/688 (11 новых), внешний симлинк не тронут. Бенч-таблица в теле PR.Внутренний цикл: 1 проход ревью (APPROVE-with-suggestions) — блокеров/регрессий не нашёл; поправил 1 nit (устаревшая ссылка в комментарии теста на несуществующий mock-файл). Свойство fail-closed и отсутствие регрессии на under-cap пути проверены адверсарно.
Немедленный митигейшн на проде —
docker restartзависшего контейнера; этот PR убирает корневую причину.Ревью — #465
fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464)Вердикт: PASS ✅
Полный веер из 9 аспектов (на develop
d287c15d, #463 влит; 3 файла, +477/−52, всё в packages/mcp). Боевой прод-инцидент. Гейт зелёный, гард адверсариально пришпилен по ВСЕМ осям.Механизм DoS-гарда — проверено (клик)
Инцидент:
diffDocsсинхронно зовётrecreateTransform(rfc6902), чей diff O(n·m) по нодам + O(w²) по словам, и который никогда не бросает — поэтому существующийcatch → coarseDiffНЕ спасал event loop (бенч: 401 нода→1.3с, 801→5.5с, 1601→OOM; 309КБ→OOM); заблокированный воркер душит все запросы = живой прод-хэнг.exceedsDiffSizeGuard(old,new)PRE-FLIGHT (diff.ts:428) ДО recreateTransform →coarseDiffTally. Триггер поmax(old,new)на ОБЕИХ осях (countNodes+JSON.stringify().length) → асимметричная пара (крошечный new / огромный old и наоборот) тоже ловится. Дёшево: один обход + один stringify на документ (O(size)).coarseDiffO(doc_size) линеен (подтверждено чтением), НЕ квадратичен.countNodes— один проход O(n). Единственная pre-guard работа — computeIntegrity (линейный обход) + stringify, обе O(n).readPositiveIntEnv— мусор/пусто/≤0/не-finite → дефолт (Number.isFinite && >0). Env-override только осознанным валидным int (operator-escape, не уязвимость).Объективный гейт (клин-рум /home/claude/gm-ci)
exceedsDiffSizeGuard→false → 10 из 11 тестов ПАДАЮТ (выживает только «under-cap проходит» — корректно). Гард load-bearing по всем осям: node-кап, byte-кап, обе асимметрии, env-override, garbage-env→дефолт, integrity-на-coarse, skip-recreate (5× speed-contrast). Restore 11/11.⛔ DROP — кодеру НЕ делать (лог калибровки)
[below-threshold]low[documentation] README не перечисляетMCP_DIFF_MAX_NODES/MCP_DIFF_MAX_BYTES— но они ПОДРОБНО задокументированы в код-комментах (diff.ts:89-107), гард работает из коробки с бенчмарк-дефолтами (override — редкое advanced-действие оператора). В отличие от активно-НЕВЕРНОГО дока (как #460, стейл-ссылка на удалённый тест — там был DO), README здесь просто НЕполон, не мислидинг. Рекомендую follow-up «Tuning»-секцию, но не блок хотфикса.DO/ESCALATE нет. Pre-flight гард закрывает CPU-DoS (фолбэк O(n), не переносит проблему), O(w²) под капом ловится байт-осью, гард fail-safe от env, обхода нет, рефактор верен, инцидент закрыт. Гейт зелёный + адверсариал (neuter→10/11 fail). Approve.
9 аспектов (stability+security+regressions+coherence, testcov+conv+docs+simp+arch — все LGTM). Гард pre-flight; coarseDiff O(n) bounded (DoS не переносится); O(w²) ловится byte-капом; env fail-safe; нет bypass. Neuter-адверсариал → 10/11 краснеют. README env-vars — below-threshold DROP (в коде задокументировано). Escalate нет.