2f23cf4b65
Боевой инцидент: 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>
87 lines
3.4 KiB
JavaScript
87 lines
3.4 KiB
JavaScript
// Issue #464 — prove the size guard SKIPS the recreateTransform pipeline over
|
|
// the cap, not merely that it returns "coarse". node:test's mock.module needs an
|
|
// experimental flag the suite does not pass, so instead of a module spy we use a
|
|
// deterministic BEHAVIORAL proxy that isolates the one variable — the guard:
|
|
//
|
|
// Same over-cap pair, run twice:
|
|
// (a) default caps -> guard trips -> recreateTransform skipped,
|
|
// (b) caps raised above the doc -> guard OFF -> recreateTransform DOES run.
|
|
//
|
|
// The only code path that differs between (a) and (b) is whether
|
|
// recreateTransform executes. recreateTransform on this pair is O(n²) and takes
|
|
// SECONDS; the guarded path is a linear coarse diff taking milliseconds. So a
|
|
// large (a)≪(b) time ratio can ONLY be explained by (a) skipping the transform.
|
|
// This asserts the skip without depending on mock.module.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { diffDocs } from "../../build/lib/diff.js";
|
|
|
|
const t = (text) => ({ type: "text", text });
|
|
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
|
|
const doc = (children) => ({ type: "doc", content: children });
|
|
function buildDoc(n, seed) {
|
|
return doc(
|
|
Array.from({ length: n }, (_, i) =>
|
|
para(Array.from({ length: 8 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
|
|
),
|
|
);
|
|
}
|
|
function clearEnv() {
|
|
delete process.env.MCP_DIFF_MAX_NODES;
|
|
delete process.env.MCP_DIFF_MAX_BYTES;
|
|
}
|
|
function timed(fn) {
|
|
const s = performance.now();
|
|
const out = fn();
|
|
return { out, ms: performance.now() - s };
|
|
}
|
|
|
|
// A 300-para (~600-node) pair: comfortably over the 150-node default, yet small
|
|
// enough that the un-guarded recreateTransform still FINISHES (~1-3s) so the
|
|
// test can time the contrast without hanging.
|
|
const OLD = buildDoc(300, "a");
|
|
const NEW = buildDoc(300, "b");
|
|
|
|
test("guard skips recreateTransform over-cap (guarded run is far faster than un-guarded)", () => {
|
|
// (a) Guarded: default caps -> should short-circuit to coarse, near-instant.
|
|
clearEnv();
|
|
const guarded = timed(() => diffDocs(OLD, NEW));
|
|
assert.match(
|
|
guarded.out.markdown,
|
|
/coarse block-level diff/,
|
|
"guarded run must be coarse (guard tripped)",
|
|
);
|
|
|
|
// (b) Un-guarded: raise both caps above the doc so the precise path runs.
|
|
process.env.MCP_DIFF_MAX_NODES = "1000000";
|
|
process.env.MCP_DIFF_MAX_BYTES = "100000000";
|
|
let unguarded;
|
|
try {
|
|
unguarded = timed(() => diffDocs(OLD, NEW));
|
|
} finally {
|
|
clearEnv();
|
|
}
|
|
assert.doesNotMatch(
|
|
unguarded.out.markdown,
|
|
/coarse block-level diff/,
|
|
"with caps raised, the precise recreateTransform path runs",
|
|
);
|
|
|
|
// The precise run executed recreateTransform (O(n²)); the guarded run did not.
|
|
// Require a large speedup so the ONLY explanation is the skipped transform.
|
|
assert.ok(
|
|
guarded.ms * 5 < unguarded.ms,
|
|
`guarded (${guarded.ms.toFixed(1)}ms) must be >=5x faster than un-guarded ` +
|
|
`(${unguarded.ms.toFixed(1)}ms); a small gap would mean the transform still ran`,
|
|
);
|
|
});
|
|
|
|
test("guarded over-cap call stays within the ~200ms event-loop budget", () => {
|
|
clearEnv();
|
|
// Best-of-3 to shed GC/JIT noise; the guarded coarse path is a linear walk.
|
|
let best = Infinity;
|
|
for (let i = 0; i < 3; i++) best = Math.min(best, timed(() => diffDocs(OLD, NEW)).ms);
|
|
assert.ok(best < 200, `guarded over-cap diff must be <200ms, was ${best.toFixed(1)}ms`);
|
|
});
|