perf(mcp): diffDocs → recreateTransform/rfc6902 без бюджета вешает event loop на большом документе (CPU-DoS) #464
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Симптом (боевой инцидент)
Контейнер
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).evicted_keys=0, CPU ~0%), retry-стратегия корректна (3–20 с). Значит потеря локов и таймауты подключений — это симптомы перегруженного event loop, а не проблема Redis.Диагностика: CPU-профиль живого процесса
Снят live CPU-профиль pid процесса через инспектор (
SIGUSR1→ CDPProfiler, 6 с). ~99% self-time — в библиотекеrfc6902(вычисление JSON Patch):Корневая причина
diffDocsсинхронно вызываетrecreateTransform(oldNode, newNode, …)на главном потоке — см.packages/mcp/src/lib/diff.ts:290:Внутри
@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:countNodes(doc, () => true)(хелпер уже есть наdiff.ts:64) и/или длинуJSON.stringify(doc). Братьmax(old, new)— маленький новый документ против огромного старого тоже даёт взрыв.recreateTransform/rfc6902, а сразу идти в существующийcoarseDiff(та же ветка, что сейчас вcatch), выставивfellBack = true.MCP_DIFF_MAX_NODES,MCP_DIFF_MAX_BYTES). Подобрать быстрым бенчмаркомrecreateTransformна синтетических документах растущего размера так, чтобы worst-case блок был ≲ ~200 мс. Ориентир для старта: ~3000–5000 узлов / ~512 КБ–1 МБ сериализованного JSON (обязательно проверить бенчмарком).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не вызывается.fellBack=false— регрессии нет.Критерии приёмки
diffDocs(и, следовательно,summarizeChange/ verify-отчёт правок) не блокирует event loop дольше ~200 мс независимо от размера входа.coarseDiff.Немедленная операционная мера
Зависший инстанс сам не восстановится (текущий diff не завершится в разумное время, память медленно растёт) — заклинивший контейнер нужно перезапустить (
docker restart gitmost), чтобы вернуть event loop, очереди и Redis-подключения. Это не устраняет причину — только этот тикет её закрывает.Ссылки (похоже по названию, но это ДРУГИЕ баги)
fix/397-mcp-hang-hardening) — зависание на сетевом MCP-хендшейке, не CPU-diff.fix/444-agent-loop-guards) — петли агента (step budget), не diff.c917dcc3— кап+таймаут для ELK-лейаута; тот же паттерн защиты, применить его же здесь.Диагностика и дизайн — Claude Code (architect). Правки кода в этом тикете не делались.