perf(mcp): diffDocs → recreateTransform/rfc6902 без бюджета вешает event loop на большом документе (CPU-DoS) #464

Open
opened 2026-07-10 20:00:02 +03:00 by agent_vscode · 0 comments
Collaborator

Симптом (боевой инцидент)

Контейнер gitmost на island.lc (10.31.40.120, образ ghcr.io/vvzvlad/gitmost:develop) держал 120–143% CPU в течение ~3 часов без единой новой строки лога (последняя запись — 16:45, обнаружено в 19:46). При этом:

  • GET /api/health отвечает (status: ok, db/redis up) — жёсткого зависания нет, но event loop заморён голодом.
  • В логах: Missing lock for job … moveToFinished/moveToDelayed, could not renew lock for job …, job stalled more than allowable limit (BullMQ) + connect ETIMEDOUT от ioredis (built/Redis.js:171).
  • Redis сам здоров (6.6 МБ, evicted_keys=0, CPU ~0%), retry-стратегия корректна (3–20 с). Значит потеря локов и таймауты подключений — это симптомы перегруженного event loop, а не проблема Redis.
  • Фоновые очереди фактически стоят: уведомления, page-watchers, эмбеддинги не обрабатываются.

Записи provider HTTP: … PRE-RESPONSE FAILED … ECONNRESET на /api/ai-chat/stream к этому инциденту не относятся — это штатная самозалечивающаяся телеметрия (#175/#310): keep-alive recycle 4 с + ретраи, запросы проходят (OK headersAfter=…). Отдельная тема.

Диагностика: CPU-профиль живого процесса

Снят live CPU-профиль pid процесса через инспектор (SIGUSR1 → CDP Profiler, 6 с). ~99% self-time — в библиотеке rfc6902 (вычисление JSON Patch):

23.8% Pointer.add          @ rfc6902/pointer.js
16.3% escapeToken          @ rfc6902/pointer.js
10.7% appendArrayOperation @ rfc6902/diff.js
10.0% Pointer.toString     @ rfc6902/pointer.js
 8.1% dist                 @ rfc6902/diff.js   ← edit-distance (Левенштейн) матрица
 6.2% subtract             @ rfc6902/diff.js
 5.8% (garbage collector)
 5.8% diffAny / diffArrays / diffObjects / intersection2 …  (всё rfc6902)
  ↑ цепочка вызова (ancestors):
     collab-session.mutate → summarizeChange → diffDocs
       → @fellow/prosemirror-recreate-transform (recreateTransform)
         → rfc6902.createPatch

Корневая причина

diffDocs синхронно вызывает recreateTransform(oldNode, newNode, …) на главном потоке — см. packages/mcp/src/lib/diff.ts:290:

const tr = recreateTransform(oldNode, newNode, {
  complexSteps: false,
  wordDiffs: true,
  simplifyDiff: true,
});

Внутри @fellow/prosemirror-recreate-transform считает разницу двух ProseMirror-документов через rfc6902.createPatch, а его диф массивов (diffArrays/dist) — это edit-distance по Левенштейну: O(n·m) по времени и памяти на каждую пару массивов, плюс рекурсия diffAny умножает стоимость. На большом/сильно изменённом документе это раздувается до фактически неограниченного времени (часы) и намертво занимает event loop.

Дыра в защите. В diffDocs есть try/catch с откатом в дешёвый coarseDiff (diff.ts:324), но он ловит только брошенные исключения. rfc6902 не бросает ошибку — он просто не завершается. Ни таймаута, ни ограничения размера входа нет.

Радиус поражения велик. Это срабатывает не только на явном инструменте диффа: mutatePageContent считает verify-отчёт через summarizeChange() → diffDocs() при каждой правке контента через MCP / in-app AI-агента (введено в a945b477). То есть любая правка достаточно большой страницы агентом может заклинить процесс. Точки вызова: packages/mcp/src/client.ts:4373, 4514, 4560.

Предлагаемый фикс

Приоритетный — pre-flight size-guard (дешёвый, детерминированный, безопасный для главного потока), ровно тот же паттерн «кап + fallback», что уже применён к ELK-лейауту drawio в #440 / c917dcc3.

В diffDocs (packages/mcp/src/lib/diff.ts:274), до recreateTransform:

  1. Посчитать дешёвую метрику размера обоих документов: countNodes(doc, () => true) (хелпер уже есть на diff.ts:64) и/или длину JSON.stringify(doc). Брать max(old, new) — маленький новый документ против огромного старого тоже даёт взрыв.
  2. Если превышен порог — не звать recreateTransform/rfc6902, а сразу идти в существующий coarseDiff (та же ветка, что сейчас в catch), выставив fellBack = true.
  3. Порог — именованная константа с env-override (MCP_DIFF_MAX_NODES, MCP_DIFF_MAX_BYTES). Подобрать быстрым бенчмарком recreateTransform на синтетических документах растущего размера так, чтобы worst-case блок был ≲ ~200 мс. Ориентир для старта: ~3000–5000 узлов / ~512 КБ–1 МБ сериализованного JSON (обязательно проверить бенчмарком).
  4. Рефактор: вынести ветку coarse-fallback в один helper, чтобы catch и size-guard не разъезжались по поведению.

computeIntegrity (diff.ts:279) дешёвый и вызывается до диффа — оставить как есть. Downstream-потребители уже толерантны к fellBack-режиму (это текущее поведение catch), менять их не нужно.

Опционально (defense-in-depth)

Синхронный JS нельзя прервать таймером на том же потоке, поэтому «настоящий» таймаут требует выноса recreateTransform в worker-thread с terminate() по дедлайну. Тяжелее; для v1 достаточно size-guard, worker-таймаут — отдельным шагом, если понадобится.

Тесты

  • Документ выше порога → diffDocs возвращается быстро, summary/changes в coarse-режиме (fellBack=true), recreateTransform не вызывается.
  • Обычный (маленький) документ → точный diff, fellBack=false — регрессии нет.
  • Асимметрия: маленький новый vs огромный старый (и наоборот) → тоже уходит в fallback.

Критерии приёмки

  • diffDocs (и, следовательно, summarizeChange / verify-отчёт правок) не блокирует event loop дольше ~200 мс независимо от размера входа.
  • Большие документы детерминированно уходят в coarseDiff.
  • Порог настраивается через env и подтверждён бенчмарком.
  • Юнит-тесты покрывают over/under-threshold и асимметрию сторон.

Немедленная операционная мера

Зависший инстанс сам не восстановится (текущий diff не завершится в разумное время, память медленно растёт) — заклинивший контейнер нужно перезапустить (docker restart gitmost), чтобы вернуть event loop, очереди и Redis-подключения. Это не устраняет причину — только этот тикет её закрывает.

Ссылки (похоже по названию, но это ДРУГИЕ баги)

  • #397 / #405 (fix/397-mcp-hang-hardening) — зависание на сетевом MCP-хендшейке, не CPU-diff.
  • #444 (fix/444-agent-loop-guards) — петли агента (step budget), не diff.
  • #440 / c917dcc3 — кап+таймаут для ELK-лейаута; тот же паттерн защиты, применить его же здесь.

Диагностика и дизайн — Claude Code (architect). Правки кода в этом тикете не делались.

## Симптом (боевой инцидент) Контейнер `gitmost` на `island.lc` (10.31.40.120, образ `ghcr.io/vvzvlad/gitmost:develop`) держал **120–143% CPU в течение ~3 часов** без единой новой строки лога (последняя запись — `16:45`, обнаружено в `19:46`). При этом: - `GET /api/health` **отвечает** (`status: ok`, db/redis up) — жёсткого зависания нет, но event loop заморён голодом. - В логах: `Missing lock for job … moveToFinished/moveToDelayed`, `could not renew lock for job …`, `job stalled more than allowable limit` (BullMQ) + `connect ETIMEDOUT` от `ioredis` (`built/Redis.js:171`). - Redis сам **здоров** (6.6 МБ, `evicted_keys=0`, CPU ~0%), retry-стратегия корректна (3–20 с). Значит потеря локов и таймауты подключений — это **симптомы** перегруженного event loop, а не проблема Redis. - Фоновые очереди фактически стоят: уведомления, page-watchers, эмбеддинги не обрабатываются. > Записи `provider HTTP: … PRE-RESPONSE FAILED … ECONNRESET` на `/api/ai-chat/stream` к этому инциденту **не относятся** — это штатная самозалечивающаяся телеметрия (#175/#310): keep-alive recycle 4 с + ретраи, запросы проходят (`OK headersAfter=…`). Отдельная тема. ## Диагностика: CPU-профиль живого процесса Снят live CPU-профиль pid процесса через инспектор (`SIGUSR1` → CDP `Profiler`, 6 с). ~99% self-time — в библиотеке `rfc6902` (вычисление JSON Patch): ``` 23.8% Pointer.add @ rfc6902/pointer.js 16.3% escapeToken @ rfc6902/pointer.js 10.7% appendArrayOperation @ rfc6902/diff.js 10.0% Pointer.toString @ rfc6902/pointer.js 8.1% dist @ rfc6902/diff.js ← edit-distance (Левенштейн) матрица 6.2% subtract @ rfc6902/diff.js 5.8% (garbage collector) 5.8% diffAny / diffArrays / diffObjects / intersection2 … (всё rfc6902) ↑ цепочка вызова (ancestors): collab-session.mutate → summarizeChange → diffDocs → @fellow/prosemirror-recreate-transform (recreateTransform) → rfc6902.createPatch ``` ## Корневая причина `diffDocs` синхронно вызывает `recreateTransform(oldNode, newNode, …)` на главном потоке — см. `packages/mcp/src/lib/diff.ts:290`: ```ts const tr = recreateTransform(oldNode, newNode, { complexSteps: false, wordDiffs: true, simplifyDiff: true, }); ``` Внутри `@fellow/prosemirror-recreate-transform` считает разницу двух ProseMirror-документов через `rfc6902.createPatch`, а его диф массивов (`diffArrays`/`dist`) — это edit-distance по Левенштейну: **O(n·m) по времени и памяти на каждую пару массивов**, плюс рекурсия `diffAny` умножает стоимость. На большом/сильно изменённом документе это раздувается до фактически неограниченного времени (часы) и намертво занимает event loop. **Дыра в защите.** В `diffDocs` есть `try/catch` с откатом в дешёвый `coarseDiff` (`diff.ts:324`), но он ловит только **брошенные исключения**. `rfc6902` не бросает ошибку — он просто не завершается. Ни таймаута, ни ограничения размера входа нет. **Радиус поражения велик.** Это срабатывает не только на явном инструменте диффа: `mutatePageContent` считает verify-отчёт через `summarizeChange() → diffDocs()` при **каждой** правке контента через MCP / in-app AI-агента (введено в `a945b477`). То есть любая правка достаточно большой страницы агентом может заклинить процесс. Точки вызова: `packages/mcp/src/client.ts:4373, 4514, 4560`. ## Предлагаемый фикс Приоритетный — **pre-flight size-guard** (дешёвый, детерминированный, безопасный для главного потока), ровно тот же паттерн «кап + fallback», что уже применён к ELK-лейауту drawio в #440 / `c917dcc3`. В `diffDocs` (`packages/mcp/src/lib/diff.ts:274`), **до** `recreateTransform`: 1. Посчитать дешёвую метрику размера обоих документов: `countNodes(doc, () => true)` (хелпер уже есть на `diff.ts:64`) и/или длину `JSON.stringify(doc)`. Брать `max(old, new)` — маленький новый документ против огромного старого тоже даёт взрыв. 2. Если превышен порог — **не звать** `recreateTransform`/`rfc6902`, а сразу идти в существующий `coarseDiff` (та же ветка, что сейчас в `catch`), выставив `fellBack = true`. 3. Порог — именованная константа с env-override (`MCP_DIFF_MAX_NODES`, `MCP_DIFF_MAX_BYTES`). Подобрать быстрым бенчмарком `recreateTransform` на синтетических документах растущего размера так, чтобы worst-case блок был **≲ ~200 мс**. Ориентир для старта: ~3000–5000 узлов / ~512 КБ–1 МБ сериализованного JSON (обязательно проверить бенчмарком). 4. Рефактор: вынести ветку coarse-fallback в один helper, чтобы `catch` и size-guard не разъезжались по поведению. `computeIntegrity` (`diff.ts:279`) дешёвый и вызывается до диффа — оставить как есть. Downstream-потребители уже толерантны к `fellBack`-режиму (это текущее поведение `catch`), менять их не нужно. ### Опционально (defense-in-depth) Синхронный JS нельзя прервать таймером на том же потоке, поэтому «настоящий» таймаут требует выноса `recreateTransform` в worker-thread с `terminate()` по дедлайну. Тяжелее; для v1 достаточно size-guard, worker-таймаут — отдельным шагом, если понадобится. ## Тесты - Документ выше порога → `diffDocs` возвращается быстро, `summary`/`changes` в coarse-режиме (`fellBack=true`), `recreateTransform` не вызывается. - Обычный (маленький) документ → точный diff, `fellBack=false` — регрессии нет. - Асимметрия: маленький новый vs огромный старый (и наоборот) → тоже уходит в fallback. ## Критерии приёмки - `diffDocs` (и, следовательно, `summarizeChange` / verify-отчёт правок) **не блокирует event loop дольше ~200 мс** независимо от размера входа. - Большие документы детерминированно уходят в `coarseDiff`. - Порог настраивается через env и подтверждён бенчмарком. - Юнит-тесты покрывают over/under-threshold и асимметрию сторон. ## Немедленная операционная мера Зависший инстанс сам не восстановится (текущий diff не завершится в разумное время, память медленно растёт) — заклинивший контейнер нужно перезапустить (`docker restart gitmost`), чтобы вернуть event loop, очереди и Redis-подключения. Это не устраняет причину — только этот тикет её закрывает. ## Ссылки (похоже по названию, но это ДРУГИЕ баги) - #397 / #405 (`fix/397-mcp-hang-hardening`) — зависание на сетевом MCP-хендшейке, не CPU-diff. - #444 (`fix/444-agent-loop-guards`) — петли агента (step budget), не diff. - #440 / `c917dcc3` — кап+таймаут для ELK-лейаута; тот же паттерн защиты, применить его же здесь. --- Диагностика и дизайн — Claude Code (architect). Правки кода в этом тикете не делались.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#464