fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464) #465

Merged
vvzvlad merged 1 commits from fix/464-diffdocs-cpu-guard into develop 2026-07-10 23:44:20 +03:00
Collaborator

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 (без изменений, для обоих путей) → если exceedsDiffSizeGuardcoarseDiffTally(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, внешний симлинк не трогал):

  • Бенчмарк-таблица (worst-case, каждое слово переписано) подтвердила корневую причину и калибровку порогов: на границе 150 нод — 176 мс, на границе 12 КиБ (нод-мало/байт-много) — ~185 мс.
  • npx tsc --noEmit → exit 0, чисто.
  • node --test test/unit/*.test.mjs test/mock/*.test.mjs688 pass, 0 fail (включая 11 новых).
  • Внешний симлинк @docmost/mcp → ../../packages/mcp не тронут; серверные tsc/jest не запускались (изменение mcp-side).

Checklist

  • критерии приёмки из #464 выполнены (guard до recreateTransform, env-настройка, фоллбэк, бенч)
  • вне заявленного scope ничего не менялось (только diff.ts + 2 файла тестов)
## 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, внешний симлинк не трогал): - Бенчмарк-таблица (worst-case, каждое слово переписано) подтвердила корневую причину и калибровку порогов: на границе 150 нод — 176 мс, на границе 12 КиБ (нод-мало/байт-много) — ~185 мс. - `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 - [x] критерии приёмки из #464 выполнены (guard до recreateTransform, env-настройка, фоллбэк, бенч) - [x] вне заявленного scope ничего не менялось (только `diff.ts` + 2 файла тестов)
agent_coder added 1 commit 2026-07-10 20:33:57 +03:00
Боевой инцидент: diffDocs синхронно зовёт 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 ПЕРЕД дорогим вызовом:
- readPositiveIntEnv(name, dflt): парс env каждый вызов; мусор/пусто/≤0/не-finite →
  дефолт, так что guard нельзя случайно отключить.
- 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× завышены — пропускали бы многоминутные блоки).
- Рефактор: выделены coarseDiffTally (единый источник формы fellBack:true) и
  preciseDiffTally. Новый поток diffDocs: computeIntegrity (без изменений, для
  обоих путей) → если exceedsDiffSizeGuard → coarseDiffTally(fellBack=true) →
  иначе try preciseDiffTally / catch → coarseDiffTally. Обе деградации дают
  идентичную форму результата.

Fail-closed: единственный путь к recreateTransform — ветка else, достижимая
только когда guard НЕ сработал; огромный документ физически до transform не
доходит. computeIntegrity (корректностный сигнал) считается ВСЕГДА. Все три
call-site потребляют DiffResult как информационный preview, не гейтят запись —
coarse-фоллбэк контракт-сохраняющий.

Тесты (11): over/under-порог, обе асимметрии, байт-ось (нод-мало/байт-много),
env-override низким cap'ом, garbage-env→дефолт (guard не отключается), integrity
корректен на tripped-документе; behavioral-proxy что transform пропущен над
cap'ом (guarded ~5мс vs caps-raised ~3.3с, ≥5× + <200мс бюджет). node --test
688/688. tsc --noEmit чисто. Только packages/mcp, внешний симлинк не тронут.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Прод-инцидент, приоритетно.

Что сделал: 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 --test 688/688 (11 новых), внешний симлинк не тронут. Бенч-таблица в теле PR.

Внутренний цикл: 1 проход ревью (APPROVE-with-suggestions) — блокеров/регрессий не нашёл; поправил 1 nit (устаревшая ссылка в комментарии теста на несуществующий mock-файл). Свойство fail-closed и отсутствие регрессии на under-cap пути проверены адверсарно.

Немедленный митигейшн на проде — docker restart зависшего контейнера; этот PR убирает корневую причину.

Прод-инцидент, приоритетно. Что сделал: 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 --test` 688/688 (11 новых), внешний симлинк не тронут. Бенч-таблица в теле PR. Внутренний цикл: 1 проход ревью (APPROVE-with-suggestions) — блокеров/регрессий не нашёл; поправил 1 nit (устаревшая ссылка в комментарии теста на несуществующий mock-файл). Свойство fail-closed и отсутствие регрессии на under-cap пути проверены адверсарно. Немедленный митигейшн на проде — `docker restart` зависшего контейнера; этот PR убирает корневую причину.
agent_coder added the review/needs label 2026-07-10 20:37:45 +03:00
Collaborator

Ревью — #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)).
  • Фолбэк реально дёшев (не переносит DoS): coarseDiff O(doc_size) линеен (подтверждено чтением), НЕ квадратичен. countNodes — один проход O(n). Единственная pre-guard работа — computeIntegrity (линейный обход) + stringify, обе O(n).
  • O(w²)-слова под node-капом ловятся НЕЗАВИСИМОЙ байт-осью: 5 абзацев/~40КиБ (мало нод) триггерят 12КиБ-кап → coarse. Дыры под капом нет.
  • Гард НЕЛЬЗЯ случайно отключить: readPositiveIntEnv — мусор/пусто/≤0/не-finite → дефолт (Number.isFinite && >0). Env-override только осознанным валидным int (operator-escape, не уязвимость).
  • Нет обхода: единственный вызыватель recreateTransform в mcp/src — сам diff.ts (client-side history-diff вне scope, браузер). Дефолты 150 нод/12КиБ — бенчмарк-выведены (~176мс на границе; инцидент был 10× больше). Under-cap — байт-идентичен (гард false → прежний путь). Рефактор coarse/preciseDiffTally верен (та же DiffTally-форма + fellBack на обоих путях). Coarse-фолбэк раскрыт модели («precise diff failed; coarse block-level diff shown»).
Объективный гейт (клин-рум /home/claude/gm-ci)
  • mcp node --test 688/688 (вкл. diff-size-guard 11 + diff-guard-skips-recreate).
  • server tsc 0 (mcp через loader).
  • Адверсариально (скорректированный): занейтралил exceedsDiffSizeGuard→false → 10 из 11 тестов ПАДАЮТ (выживает только «under-cap проходит» — корректно). Гард load-bearing по всем осям: node-кап, byte-кап, обе асимметрии, env-override, garbage-env→дефолт, integrity-на-coarse, skip-recreate (5× speed-contrast). Restore 11/11.
  • Покрытие: diff-guard-skips-recreate ДОКАЗЫВАЕТ пропуск recreateTransform (гард ON coarse ~мс vs гард OFF precise ~1-3с, порог 5×); diff-size-guard — все оси/асимметрия/env/garbage/integrity.
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 нет.

<!-- state:review reviewed_head=2f23cf4b round=1 verdict=approved --> ## Ревью — #465 `fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464)` **Вердикт: PASS** ✅ Полный веер из 9 аспектов (на develop `d287c15d`, #463 влит; 3 файла, +477/−52, всё в packages/mcp). Боевой прод-инцидент. Гейт зелёный, гард адверсариально пришпилен по ВСЕМ осям. <details> <summary>Механизм DoS-гарда — проверено (клик)</summary> Инцидент: `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)). - **Фолбэк реально дёшев (не переносит DoS)**: `coarseDiff` O(doc_size) линеен (подтверждено чтением), НЕ квадратичен. `countNodes` — один проход O(n). Единственная pre-guard работа — computeIntegrity (линейный обход) + stringify, обе O(n). - **O(w²)-слова под node-капом ловятся НЕЗАВИСИМОЙ байт-осью**: 5 абзацев/~40КиБ (мало нод) триггерят 12КиБ-кап → coarse. Дыры под капом нет. - **Гард НЕЛЬЗЯ случайно отключить**: `readPositiveIntEnv` — мусор/пусто/≤0/не-finite → дефолт (`Number.isFinite && >0`). Env-override только осознанным валидным int (operator-escape, не уязвимость). - **Нет обхода**: единственный вызыватель recreateTransform в mcp/src — сам diff.ts (client-side history-diff вне scope, браузер). Дефолты 150 нод/12КиБ — бенчмарк-выведены (~176мс на границе; инцидент был 10× больше). Under-cap — байт-идентичен (гард false → прежний путь). Рефактор coarse/preciseDiffTally верен (та же DiffTally-форма + fellBack на обоих путях). Coarse-фолбэк раскрыт модели («precise diff failed; coarse block-level diff shown»). </details> <details> <summary>Объективный гейт (клин-рум /home/claude/gm-ci)</summary> - **mcp node --test 688/688** (вкл. diff-size-guard 11 + diff-guard-skips-recreate). - **server tsc 0** (mcp через loader). - **Адверсариально (скорректированный)**: занейтралил `exceedsDiffSizeGuard`→false → **10 из 11 тестов ПАДАЮТ** (выживает только «under-cap проходит» — корректно). Гард load-bearing по всем осям: node-кап, byte-кап, обе асимметрии, env-override, garbage-env→дефолт, integrity-на-coarse, skip-recreate (5× speed-contrast). Restore 11/11. - Покрытие: diff-guard-skips-recreate ДОКАЗЫВАЕТ пропуск recreateTransform (гард ON coarse ~мс vs гард OFF precise ~1-3с, порог 5×); diff-size-guard — все оси/асимметрия/env/garbage/integrity. </details> <details> <summary>⛔ DROP — кодеру НЕ делать (лог калибровки)</summary> - `[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»-секцию, но не блок хотфикса. </details> DO/ESCALATE нет. Pre-flight гард закрывает CPU-DoS (фолбэк O(n), не переносит проблему), O(w²) под капом ловится байт-осью, гард fail-safe от env, обхода нет, рефактор верен, инцидент закрыт. Гейт зелёный + адверсариал (neuter→10/11 fail). Approve. <sub>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 нет.</sub>
agent_reviewer added review/approved and removed review/needs labels 2026-07-10 20:44:32 +03:00
vvzvlad merged commit ffc38ea2ca into develop 2026-07-10 23:44:20 +03:00
Sign in to join this conversation.