Files
gitmost/packages/mcp/test/unit/diff-size-guard.test.mjs
T
agent_coder 2f23cf4b65 fix(mcp): pre-flight size-guard в diffDocs против CPU-DoS на больших правках (#464)
Боевой инцидент: 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>
2026-07-10 20:32:50 +03:00

230 lines
9.6 KiB
JavaScript

// Issue #464 — prod CPU-DoS pre-flight size guard for diffDocs.
//
// diffDocs synchronously calls recreateTransform (rfc6902) which is O(n·m) in
// node count and O(w²) in per-run word count; on a large/heavily-changed doc it
// pins the event loop for seconds-to-hours WITHOUT throwing. A pre-flight size
// guard routes any doc over MCP_DIFF_MAX_NODES / MCP_DIFF_MAX_BYTES straight to
// the coarse fallback (`fellBack:true`), so the sync block stays ~<200ms.
//
// These tests assert the BEHAVIOR of the guard (fast + coarse-mode + asymmetry +
// env knobs). A sibling test (diff-guard-skips-recreate.test.mjs) proves
// recreateTransform is skipped over the cap via a behavioral proxy (guarded run
// is orders of magnitude faster than the same pair with the caps raised).
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffDocs } from "../../build/lib/diff.js";
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
const t = (text) => ({ type: "text", text });
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
const doc = (children) => ({ type: "doc", content: children });
/** A doc of `n` paragraphs whose words are seeded from `seed` (fully changeable). */
function buildDoc(n, wordsPerPara, seed) {
const blocks = [];
for (let i = 0; i < n; i++) {
const words = [];
for (let w = 0; w < wordsPerPara; w++) words.push(`${seed}${i}_${w}`);
blocks.push(para(words.join(" ")));
}
return doc(blocks);
}
/** Reset the env knobs to their unset default between tests. */
function clearEnv() {
delete process.env.MCP_DIFF_MAX_NODES;
delete process.env.MCP_DIFF_MAX_BYTES;
}
// ---------------------------------------------------------------------------
// Over-threshold (by node count) -> FAST + coarse mode.
// A fully re-written 600-para doc is the worst case that drove the incident;
// with the guard it must return in well under the ~200ms budget and in coarse
// mode. Without the guard this single call takes multiple SECONDS.
// ---------------------------------------------------------------------------
test("over-threshold doc falls back to coarse mode and returns fast", () => {
clearEnv();
// 600 paragraphs -> ~1200 nodes, far over the 150-node default.
const oldDoc = buildDoc(600, 8, "a");
const newDoc = buildDoc(600, 8, "b");
const start = performance.now();
const r = diffDocs(oldDoc, newDoc);
const elapsed = performance.now() - start;
// Coarse mode is signalled in the markdown note (fellBack path).
assert.match(
r.markdown,
/coarse block-level diff/,
"over-threshold pair must use the coarse fallback",
);
// Budget: the guard makes this near-instant. Generous 1s ceiling to avoid CI
// flake while still being ~10x under the multi-second un-guarded cost.
assert.ok(
elapsed < 1000,
`expected fast coarse fallback, took ${elapsed.toFixed(0)}ms`,
);
// Coarse diff still detects the wholesale change.
assert.ok(r.summary.inserted > 0 || r.summary.deleted > 0, "reports changes");
});
// ---------------------------------------------------------------------------
// Under-threshold (small) doc -> precise diff, NOT coarse mode. No regression.
// ---------------------------------------------------------------------------
test("under-threshold doc uses the precise diff (no fallback note)", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
const r = diffDocs(oldDoc, newDoc);
assert.doesNotMatch(
r.markdown,
/coarse block-level diff/,
"a small doc must take the precise path",
);
// Precise word diff finds exactly the inserted word.
const ins = r.changes.find((c) => c.op === "insert");
assert.ok(ins && /brave/.test(ins.text), "precise diff isolates the inserted word");
});
// ---------------------------------------------------------------------------
// Asymmetry: a small NEW doc vs a huge OLD doc (and vice versa) still explodes
// rfc6902, so max(old,new) must trip the guard in BOTH directions.
// ---------------------------------------------------------------------------
test("asymmetric pair (huge old, tiny new) falls back to coarse", () => {
clearEnv();
const hugeOld = buildDoc(600, 8, "a");
const tinyNew = doc([para("just one line")]);
const r = diffDocs(hugeOld, tinyNew);
assert.match(r.markdown, /coarse block-level diff/, "huge-old side must trip the guard");
});
test("asymmetric pair (tiny old, huge new) falls back to coarse", () => {
clearEnv();
const tinyOld = doc([para("just one line")]);
const hugeNew = buildDoc(600, 8, "b");
const r = diffDocs(tinyOld, hugeNew);
assert.match(r.markdown, /coarse block-level diff/, "huge-new side must trip the guard");
});
// ---------------------------------------------------------------------------
// Byte axis: a FEW nodes but a very large serialized size (long text runs) is
// dangerous too (per-run word diff is O(words²)), so the byte cap must trip
// independently of the node count.
// ---------------------------------------------------------------------------
test("node-light but byte-heavy doc falls back on the byte cap", () => {
clearEnv();
// 5 paragraphs (~11 nodes, well under the node cap) but each a very long run,
// pushing the serialized size far over the 12 KiB byte default.
const bigRun = (seed) =>
doc(
Array.from({ length: 5 }, (_, i) =>
para(Array.from({ length: 800 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
),
);
const oldDoc = bigRun("a");
const newDoc = bigRun("b");
// Sanity: node count is under the default node cap, so ONLY the byte cap can
// be what trips the guard here.
const nodeCount = (d) => {
let n = 0;
const v = (x) => {
if (!x || typeof x !== "object") return;
n++;
if (Array.isArray(x.content)) for (const c of x.content) v(c);
};
v(d);
return n;
};
assert.ok(nodeCount(oldDoc) < 150, "node count is under the node cap");
assert.ok(JSON.stringify(oldDoc).length > 12 * 1024, "serialized size is over the byte cap");
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "byte cap must trip independently");
});
// ---------------------------------------------------------------------------
// Env override: a very low MCP_DIFF_MAX_NODES forces fallback on a tiny doc,
// proving the knob is read fresh and actually gates the diff.
// ---------------------------------------------------------------------------
test("MCP_DIFF_MAX_NODES override forces fallback on a small doc", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
// Baseline: default caps -> precise diff.
assert.doesNotMatch(diffDocs(oldDoc, newDoc).markdown, /coarse block-level diff/);
// Knob set absurdly low -> even this 4-node doc trips the guard.
process.env.MCP_DIFF_MAX_NODES = "1";
try {
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "low node cap forces fallback");
} finally {
clearEnv();
}
});
test("MCP_DIFF_MAX_BYTES override forces fallback on a small doc", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
process.env.MCP_DIFF_MAX_BYTES = "1";
try {
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "low byte cap forces fallback");
} finally {
clearEnv();
}
});
// ---------------------------------------------------------------------------
// Garbage / unset env values fall back to the DEFAULT (the guard can never be
// accidentally disabled by a malformed knob). A small doc must still diff
// precisely under a garbage cap.
// ---------------------------------------------------------------------------
test("garbage env values fall back to the default cap (guard not disabled)", () => {
clearEnv();
const oldDoc = doc([para("Hello world")]);
const newDoc = doc([para("Hello brave world")]);
for (const bad of ["not-a-number", "0", "-5", "", "NaN", "1e999"]) {
process.env.MCP_DIFF_MAX_NODES = bad;
process.env.MCP_DIFF_MAX_BYTES = bad;
// Under the DEFAULT caps this small doc is precise (garbage did not raise
// OR disable the cap). "1e999" -> parseInt yields 1 (finite) which is a
// valid low cap and would fall back; exclude that from the precise check.
const r = diffDocs(oldDoc, newDoc);
if (bad === "1e999") {
// parseInt("1e999",10) === 1 -> a legit low cap -> fallback. Guard active.
assert.match(r.markdown, /coarse block-level diff/);
} else {
assert.doesNotMatch(
r.markdown,
/coarse block-level diff/,
`garbage value ${JSON.stringify(bad)} must fall back to the default cap`,
);
}
}
clearEnv();
});
// ---------------------------------------------------------------------------
// A large doc that trips the guard must still return the correct INTEGRITY
// counts (computeIntegrity runs before the diff and is unaffected by fallback).
// ---------------------------------------------------------------------------
test("integrity counts are still correct on a guard-tripped (coarse) doc", () => {
clearEnv();
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
const oldDoc = doc([image, ...buildDoc(600, 8, "a").content]);
const newDoc = doc([...buildDoc(600, 8, "b").content]); // image removed
const r = diffDocs(oldDoc, newDoc);
assert.match(r.markdown, /coarse block-level diff/, "large pair fell back");
assert.deepEqual(r.integrity.images, [1, 0], "integrity is computed regardless of fallback");
});