Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a8671c3af | |||
| bec2156e96 | |||
| a49872444e | |||
| ffc38ea2ca | |||
| 2f23cf4b65 | |||
| d287c15db4 |
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
||||
# MCP_TOKEN=
|
||||
# MCP_SESSION_IDLE_MS=1800000
|
||||
#
|
||||
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||
#
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||
# two replicas can each "hold" the lock for the same page at the same time and
|
||||
# serialization is silently lost (concurrent full-document writes race on the
|
||||
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||
#
|
||||
# To reduce connect-storms the write path caches ONE live collab session per
|
||||
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||
#
|
||||
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||
#
|
||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||
# content + images to an external consumer WITHOUT bloating the model context or
|
||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||
|
||||
@@ -157,6 +157,12 @@ jobs:
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
||||
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
||||
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
||||
- name: Build mcp
|
||||
run: pnpm --filter @docmost/mcp build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
|
||||
+10
@@ -81,4 +81,14 @@ VOLUME ["/app/data/storage"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||
# sessions / consistent hashing on page id). There is deliberately no
|
||||
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -182,6 +182,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
isAiChatResumableStreamEnabled: () => true,
|
||||
isAiChatFinalStepLockdownEnabled: () => false,
|
||||
} as any,
|
||||
registry,
|
||||
);
|
||||
@@ -499,6 +500,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
isAiChatResumableStreamEnabled: () => true,
|
||||
isAiChatFinalStepLockdownEnabled: () => false,
|
||||
} as any,
|
||||
registry,
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
|
||||
{} as any, // pageAccess (idem)
|
||||
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||
// harness so the toolset/behavior is exactly as before.
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
|
||||
{} as any,
|
||||
{} as any,
|
||||
// #332: deferred tool loading ON — the property under test.
|
||||
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -293,8 +293,28 @@ so capable clients steer the model automatically.
|
||||
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
|
||||
tick so no remote update can interleave, and **waits for persistence acknowledgement**
|
||||
before returning.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex ensures two MCP writes to
|
||||
the same page never overlap; different pages never block each other.
|
||||
- **Per-page write serialization.** A per-`pageId` async mutex (keyed by the resolved
|
||||
page **UUID**, never a slugId) ensures two MCP writes to the same page never overlap;
|
||||
different pages never block each other. The lock helper fails fast if it is ever handed
|
||||
a non-UUID key, so a write path that forgot to resolve the id can never silently lock
|
||||
under a split key.
|
||||
|
||||
**Deploy requirement — single instance or sticky sessions.** This mutex is an
|
||||
in-process `Map`, and the cached collab sessions and the `stash_page` blob store are
|
||||
RAM-only and process-local. Behind a **multi-replica** load balancer **without sticky
|
||||
sessions**, two replicas can each "hold" the lock for the same page at once and per-page
|
||||
serialization is silently lost. Run the MCP/app as a **single instance**, or pin each
|
||||
page's traffic to one replica (sticky sessions / consistent hashing on the page id).
|
||||
There is deliberately no cross-process (e.g. Postgres advisory) lock yet — a conscious
|
||||
documented constraint. See the `Dockerfile` comment and the `MCP collaboration write
|
||||
path` block in `.env.example`.
|
||||
|
||||
**Rights-staleness window.** A cached collab session writes under the token captured at
|
||||
connect time (and the collab-token cache reuses a token for its TTL), so a **revoked**
|
||||
page access can lag by up to `MCP_COLLAB_SESSION_MAX_AGE_MS` (the hard session lifetime,
|
||||
default 10 min) before the next re-auth picks it up. Lower it to shorten the lag at the
|
||||
cost of more reconnects. This bounded window is an accepted trade-off; there is no
|
||||
push-based cache invalidation on a rights change.
|
||||
- **Transparent re-authentication.** Login uses email/password; expired tokens are
|
||||
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
||||
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
||||
|
||||
@@ -302,9 +302,27 @@ Docmost-MCP не сочетают:
|
||||
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
|
||||
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
|
||||
**ждёт подтверждения сохранения** до возврата.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по `pageId` гарантирует, что
|
||||
две записи MCP в одну страницу никогда не пересекаются; разные страницы друг друга не
|
||||
блокируют.
|
||||
- **Сериализация записи по странице.** Асинхронный мьютекс по разрешённому **UUID**
|
||||
страницы (никогда не по slugId) гарантирует, что две записи MCP в одну страницу никогда
|
||||
не пересекаются; разные страницы друг друга не блокируют. Хелпер блокировки падает сразу
|
||||
(fail-fast), если ему передали не-UUID ключ, — путь записи, забывший разрезолвить id, не
|
||||
сможет молча взять лок под расщеплённым ключом.
|
||||
|
||||
**Требование к деплою — один инстанс или sticky-сессии.** Этот мьютекс — процесс-локальный
|
||||
`Map`, а кэш collab-сессий и хранилище `stashPage` живут только в RAM одного процесса. За
|
||||
**мультиреплика**-балансировщиком **без sticky-сессий** две реплики могут одновременно
|
||||
«держать» лок одной страницы, и сериализация по странице молча теряется. Запускайте
|
||||
MCP/приложение **одним инстансом** либо прибивайте трафик страницы к одной реплике
|
||||
(sticky-сессии / consistent hashing по id страницы). Кросс-процессной блокировки (например,
|
||||
Postgres advisory-lock) намеренно пока нет — осознанное задокументированное ограничение.
|
||||
См. комментарий в `Dockerfile` и блок `MCP collaboration write path` в `.env.example`.
|
||||
|
||||
**Окно устаревших прав.** Кэшированная collab-сессия пишет под токеном, захваченным в
|
||||
момент connect (а кэш collab-токена переиспользует токен в пределах своего TTL), поэтому
|
||||
**отозванный** доступ к странице может лагать до `MCP_COLLAB_SESSION_MAX_AGE_MS` (жёсткий
|
||||
срок жизни сессии, по умолчанию 10 мин), пока следующая переавторизация его не подхватит.
|
||||
Уменьшите значение, чтобы сократить лаг ценой большего числа переподключений. Это
|
||||
ограниченное окно — принятый trade-off; push-инвалидации кэша при смене прав нет.
|
||||
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
|
||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||
|
||||
+27
-16
@@ -53,7 +53,7 @@ import {
|
||||
findUnrepresentableTableAttrs,
|
||||
} from "./lib/markdown-fragment.js";
|
||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import { withPageLock, isUuid } from "./lib/page-lock.js";
|
||||
import {
|
||||
prepareModel,
|
||||
decodeDrawioSvg,
|
||||
@@ -175,17 +175,14 @@ export type DocmostMcpConfig = { apiUrl: string } & (
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). page.repo.ts treats any non-UUID pageId
|
||||
// as a slugId, so the MCP detects a UUID locally and skips a /pages/info
|
||||
// round-trip in resolvePageId. A 10-char nanoid slugId never contains dashes,
|
||||
// so it can never be misread as a UUID here.
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
// Canonical UUID predicate. Single source of truth lives in page-lock.ts (the
|
||||
// module that ASSERTS the mutex key is a UUID, issue #449) and is reused here so
|
||||
// resolvePageId's "already a UUID?" short-circuit and withPageLock's fail-fast
|
||||
// assert can never diverge. page.repo.ts treats any non-UUID pageId as a slugId,
|
||||
// so the MCP detects a UUID locally and skips a /pages/info round-trip in
|
||||
// resolvePageId. A 10-char nanoid slugId never contains dashes, so it can never
|
||||
// be misread as a UUID here.
|
||||
// (isUuid imported from ./lib/page-lock.js; see import block above.)
|
||||
|
||||
/**
|
||||
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
|
||||
@@ -2185,14 +2182,23 @@ export class DocmostClient {
|
||||
* delegates; it exists as an overridable method so the insertFootnote wrapper
|
||||
* (transform abort-on-not-found + response shaping) can be unit-tested without
|
||||
* standing up a live Hocuspocus collab socket.
|
||||
*
|
||||
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||
* lock"): every write must lock and key its CollabSession by the UUID, never a
|
||||
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
|
||||
* already resolved pays no extra round-trip; centralizing it here means a
|
||||
* caller that reaches this seam with a raw slugId still locks correctly instead
|
||||
* of silently splitting the mutex key. withPageLock also asserts the key is a
|
||||
* UUID as a hard backstop.
|
||||
*/
|
||||
protected mutatePage(
|
||||
protected async mutatePage(
|
||||
pageId: string,
|
||||
collabToken: string,
|
||||
apiUrl: string,
|
||||
transform: (doc: any) => any,
|
||||
): Promise<{ doc?: any; verify?: any }> {
|
||||
return mutatePageContent(pageId, collabToken, apiUrl, transform);
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
return mutatePageContent(pageUuid, collabToken, apiUrl, transform);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2200,14 +2206,19 @@ export class DocmostClient {
|
||||
* just delegates; it exists as an overridable method so the full-doc write
|
||||
* tools (updatePageJson, copyPageContent) can have their footnote-
|
||||
* canonicalization binding unit-tested without a live Hocuspocus collab socket.
|
||||
*
|
||||
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||
* lock") for the same reason as mutatePage above — the lock/CollabSession key
|
||||
* is guaranteed canonical here, not left to the caller's discipline.
|
||||
*/
|
||||
protected replacePage(
|
||||
protected async replacePage(
|
||||
pageId: string,
|
||||
doc: any,
|
||||
collabToken: string,
|
||||
apiUrl: string,
|
||||
): Promise<{ doc?: any; verify?: any }> {
|
||||
return replacePageContent(pageId, doc, collabToken, apiUrl);
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
return replacePageContent(pageUuid, doc, collabToken, apiUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -440,9 +440,16 @@ export class CollabSession {
|
||||
// must stay synchronous (no await). While the JS event loop is not
|
||||
// yielded, no incoming remote update can interleave, so any already-synced
|
||||
// concurrent edits are preserved in liveDoc.
|
||||
//
|
||||
// INVARIANT 1 is machine-checked: the BEGIN/END markers below delimit the
|
||||
// no-await window, and test/unit/no-await-critical-window.test.mjs scans
|
||||
// this source and FAILS if any `await` (or `for await`/`yield`) appears
|
||||
// between them. Do NOT add an await inside this block — an accidental
|
||||
// async boundary here silently reopens the clobber-live-edits race (#152).
|
||||
let newDoc: any;
|
||||
let beforeDoc: any;
|
||||
try {
|
||||
// === MUTATE-CRITICAL-WINDOW: BEGIN (no await between here and END #449) ===
|
||||
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
||||
if (
|
||||
!liveDoc ||
|
||||
@@ -480,6 +487,7 @@ export class CollabSession {
|
||||
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
||||
// end of the document on every agent write.
|
||||
applyDocToFragment(this.ydoc, newDoc);
|
||||
// === MUTATE-CRITICAL-WINDOW: END (#449) ===
|
||||
} catch (e) {
|
||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||
// "text not found"): propagate them verbatim to the caller.
|
||||
|
||||
+161
-51
@@ -14,7 +14,19 @@
|
||||
* signature.
|
||||
*
|
||||
* If recreateTransform / the changeset throws on a pathological document pair,
|
||||
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
||||
* OR the pair is too large to diff cheaply (see the size guard below), we fall
|
||||
* back to a coarse block-level text diff so the tool never hard-fails and never
|
||||
* pins the event loop.
|
||||
*
|
||||
* SIZE GUARD (issue #464 — prod CPU-DoS). recreateTransform computes its diff via
|
||||
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
|
||||
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
|
||||
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
|
||||
* embeddings). It never THROWS — it just never finishes — so the try/catch below
|
||||
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
|
||||
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
|
||||
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
|
||||
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
|
||||
*/
|
||||
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
@@ -72,6 +84,56 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
// --- Issue #464: pre-flight size guard for the precise diff ------------------
|
||||
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
|
||||
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
|
||||
// re-written doc — the adversarial shape that drove the incident) keeps the
|
||||
// synchronous block under ~200ms REGARDLESS of input:
|
||||
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
|
||||
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
|
||||
// 800 ~5.5s), so cap just below the crossover.
|
||||
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
|
||||
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
|
||||
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
|
||||
// but byte-heavy doc is still refused.
|
||||
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
|
||||
// for operators who accept more CPU in exchange for exact diffs on larger docs.
|
||||
const DEFAULT_MAX_NODES = 150;
|
||||
const DEFAULT_MAX_BYTES = 12 * 1024;
|
||||
|
||||
/**
|
||||
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
|
||||
* non-finite / non-positive all fall back (so the guard can never be accidentally
|
||||
* disabled by a malformed value). Read fresh on every call so a test / operator
|
||||
* can flip the knob without a restart.
|
||||
*/
|
||||
function readPositiveIntEnv(name: string, dflt: number): number {
|
||||
const raw = parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the pair is too large for the precise (recreateTransform) diff and
|
||||
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
|
||||
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
|
||||
* versa) — which still explodes rfc6902 — is caught. Cheap: one node walk +
|
||||
* one JSON.stringify per doc, both O(size).
|
||||
*/
|
||||
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
|
||||
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
|
||||
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
|
||||
const nodes = Math.max(
|
||||
countNodes(oldDoc, () => true),
|
||||
countNodes(newDoc, () => true),
|
||||
);
|
||||
if (nodes > maxNodes) return true;
|
||||
const bytes = Math.max(
|
||||
JSON.stringify(oldDoc)?.length ?? 0,
|
||||
JSON.stringify(newDoc)?.length ?? 0,
|
||||
);
|
||||
return bytes > maxBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
||||
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
||||
@@ -226,6 +288,81 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Accumulated textual changes plus their derived char/block tallies. */
|
||||
interface DiffTally {
|
||||
changes: DiffChange[];
|
||||
inserted: number;
|
||||
deleted: number;
|
||||
changedBlocks: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
|
||||
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
|
||||
* pre-flight size guard and the recreateTransform catch) so they behave and
|
||||
* report identically.
|
||||
*/
|
||||
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
|
||||
const changes = coarseDiff(oldDoc, newDoc);
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
const changedBlocks = new Set<string>();
|
||||
for (const c of changes) {
|
||||
if (c.op === "insert") inserted += c.text.length;
|
||||
else deleted += c.text.length;
|
||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||
}
|
||||
return { changes, inserted, deleted, changedBlocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
|
||||
* gate this behind the size guard (it can block the event loop for a large pair)
|
||||
* and wrap it in try/catch (a pathological pair can throw); on either the guard
|
||||
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
|
||||
* `coarseDiffTally` so both produce the same `DiffTally` shape.
|
||||
*/
|
||||
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
|
||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||
const tr = recreateTransform(oldNode, newNode, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
|
||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||
|
||||
const changes: DiffChange[] = [];
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
const changedBlocks = new Set<string>();
|
||||
|
||||
for (const change of simplified) {
|
||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||
if (change.toA > change.fromA) {
|
||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
deleted += text.length;
|
||||
const block = blockContextAt(oldNode, change.fromA);
|
||||
changes.push({ op: "delete", block, text });
|
||||
if (block) changedBlocks.add("d:" + block);
|
||||
}
|
||||
}
|
||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||
if (change.toB > change.fromB) {
|
||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
inserted += text.length;
|
||||
const block = blockContextAt(newNode, change.fromB);
|
||||
changes.push({ op: "insert", block, text });
|
||||
if (block) changedBlocks.add("i:" + block);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { changes, inserted, deleted, changedBlocks };
|
||||
}
|
||||
|
||||
/** Build the human-readable unified-ish markdown summary. */
|
||||
function renderMarkdown(
|
||||
result: Omit<DiffResult, "markdown">,
|
||||
@@ -276,66 +413,39 @@ export function diffDocs(
|
||||
newDocJson: any,
|
||||
notesHeading: string = "Примечания переводчика",
|
||||
): DiffResult {
|
||||
// computeIntegrity is cheap (linear node walks) and its counts are needed in
|
||||
// BOTH the precise and coarse paths, so it always runs first.
|
||||
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
||||
|
||||
let changes: DiffChange[] = [];
|
||||
let inserted = 0;
|
||||
let deleted = 0;
|
||||
let fellBack = false;
|
||||
const changedBlocks = new Set<string>();
|
||||
let tally: DiffTally;
|
||||
|
||||
try {
|
||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||
const tr = recreateTransform(oldNode, newNode, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
const changeSet = ChangeSet.create(oldNode).addSteps(
|
||||
tr.doc,
|
||||
tr.mapping.maps,
|
||||
[],
|
||||
);
|
||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||
|
||||
for (const change of simplified) {
|
||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||
if (change.toA > change.fromA) {
|
||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
deleted += text.length;
|
||||
const block = blockContextAt(oldNode, change.fromA);
|
||||
changes.push({ op: "delete", block, text });
|
||||
if (block) changedBlocks.add("d:" + block);
|
||||
}
|
||||
}
|
||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||
if (change.toB > change.fromB) {
|
||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||
if (text.length > 0) {
|
||||
inserted += text.length;
|
||||
const block = blockContextAt(newNode, change.fromB);
|
||||
changes.push({ op: "insert", block, text });
|
||||
if (block) changedBlocks.add("i:" + block);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||
// Pre-flight size guard (#464): a too-large pair would make recreateTransform
|
||||
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to
|
||||
// the coarse fallback BEFORE calling recreateTransform at all. Both this path
|
||||
// and the catch below go through coarseDiffTally for an identical `fellBack`
|
||||
// result shape.
|
||||
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) {
|
||||
fellBack = true;
|
||||
changes = coarseDiff(oldDocJson, newDocJson);
|
||||
for (const c of changes) {
|
||||
if (c.op === "insert") inserted += c.text.length;
|
||||
else deleted += c.text.length;
|
||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||
} else {
|
||||
try {
|
||||
tally = preciseDiffTally(oldDocJson, newDocJson);
|
||||
} catch {
|
||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||
fellBack = true;
|
||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||
}
|
||||
}
|
||||
|
||||
const partial: Omit<DiffResult, "markdown"> = {
|
||||
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
|
||||
summary: {
|
||||
inserted: tally.inserted,
|
||||
deleted: tally.deleted,
|
||||
blocksChanged: tally.changedBlocks.size,
|
||||
},
|
||||
integrity,
|
||||
changes,
|
||||
changes: tally.changes,
|
||||
};
|
||||
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
|
||||
const chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
// that the server's isValidUUID uses). This is the SINGLE source of truth for
|
||||
// "is this a canonical page UUID?" in the MCP: client.ts's resolvePageId
|
||||
// imports isUuid from here to decide whether a pageId already IS a UUID (and so
|
||||
// needs no /pages/info round-trip). page.repo.ts treats any non-UUID pageId as
|
||||
// a slugId; a 10-char nanoid slugId never contains dashes, so it can never be
|
||||
// misread as a UUID here.
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(value: string): boolean {
|
||||
return typeof value === "string" && UUID_RE.test(value);
|
||||
}
|
||||
|
||||
// The returned promise carries the real result/rejection of `fn` and MUST be
|
||||
// awaited/handled by the caller; only the internal chaining tail swallows
|
||||
// errors (purely to gate ordering).
|
||||
@@ -17,6 +31,25 @@ export function withPageLock<T>(
|
||||
pageId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
// STRUCTURAL INVARIANT (issue #449, "resolve-then-lock"): the mutex key MUST
|
||||
// be the canonical page UUID, never a raw slugId. The whole write path relies
|
||||
// on the lock key AND the CollabSession cache key being the resolved UUID
|
||||
// (#260) — if a future write method forgot to call resolvePageId and locked
|
||||
// under a slugId, two writes to the same page would take DIFFERENT mutex keys
|
||||
// and silently lose serialization (clobbering live human edits). This was an
|
||||
// invariant enforced only by comments/convention; assert it in CODE so the
|
||||
// violation fails fast and loud at the lock instead of corrupting data in
|
||||
// prod. The centralizing helper (mutatePageContent/replacePageContent) already
|
||||
// guards a raw-input caller, but this backstop catches ANY path.
|
||||
if (!isUuid(pageId)) {
|
||||
throw new Error(
|
||||
`withPageLock: key must be a canonical page UUID, got '${pageId}'. ` +
|
||||
`The write path must resolvePageId(pageId) BEFORE locking so the ` +
|
||||
`mutex/CollabSession cache key is the UUID (invariant "resolve-then-` +
|
||||
`lock", #260/#449). A slugId or other non-UUID key would silently lose ` +
|
||||
`per-page serialization.`,
|
||||
);
|
||||
}
|
||||
// Wait for the previous op on this page; swallow its error so a failure does
|
||||
// not poison the queue for the next caller.
|
||||
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => {});
|
||||
|
||||
@@ -372,7 +372,11 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
|
||||
// single flush. This proves the flush actually executes queued callbacks, so
|
||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||
let freeRan = false;
|
||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
||||
// A DIFFERENT canonical UUID (unrelated to the page under test). withPageLock
|
||||
// now asserts its key is a canonical UUID (#449), so the "free" probe key must
|
||||
// also be a valid — but distinct — UUID, not a synthetic label.
|
||||
const FREE_UUID = "99999999-9999-4999-8999-999999999999";
|
||||
const freeDone = withPageLock(FREE_UUID, async () => {
|
||||
freeRan = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
@@ -323,7 +323,9 @@ test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)",
|
||||
});
|
||||
|
||||
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
|
||||
const pageId = "page-lock";
|
||||
// withPageLock now asserts a canonical UUID key (#449); this flow takes the
|
||||
// real page lock (mirroring replaceImage), so the key must be a valid UUID.
|
||||
const pageId = "77777777-7777-4777-8777-777777777777";
|
||||
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
|
||||
// each going through the non-locking acquireCollabSession.
|
||||
const result = await withPageLock(pageId, async () => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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`);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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");
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Issue #449, invariant 2 ("no-await-окно"): the atomicity of the
|
||||
// read -> transform -> write section in CollabSession.mutate depends on there
|
||||
// being NO `await` (nor any other async yield point) between
|
||||
// `TiptapTransformer.fromYdoc` and `applyDocToFragment`. Yjs applies queued
|
||||
// remote updates only when the event loop yields, so an accidental await in that
|
||||
// window would let a concurrent human edit interleave and be clobbered (#152).
|
||||
//
|
||||
// That was an invariant enforced only by a comment. This test turns a violation
|
||||
// RED: it reads the SOURCE of collab-session.ts, extracts the block delimited by
|
||||
// the machine-readable BEGIN/END markers, and asserts no async boundary appears
|
||||
// inside it. Introducing an `await` (or `for await`, or `yield`) between the
|
||||
// markers fails this test.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
// Scan the .ts SOURCE (not the compiled .js): the markers live in the source and
|
||||
// transpilation could rewrite/erase them, so the source is the authoritative
|
||||
// artifact the human edits.
|
||||
const sourcePath = join(here, "..", "..", "src", "lib", "collab-session.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
|
||||
const BEGIN = "=== MUTATE-CRITICAL-WINDOW: BEGIN";
|
||||
const END = "=== MUTATE-CRITICAL-WINDOW: END";
|
||||
|
||||
test("critical-window markers exist exactly once each", () => {
|
||||
const begins = source.split(BEGIN).length - 1;
|
||||
const ends = source.split(END).length - 1;
|
||||
assert.equal(
|
||||
begins,
|
||||
1,
|
||||
`expected exactly one '${BEGIN}' marker, found ${begins}`,
|
||||
);
|
||||
assert.equal(ends, 1, `expected exactly one '${END}' marker, found ${ends}`);
|
||||
});
|
||||
|
||||
test("the read->write critical window contains no async boundary (no await/yield)", () => {
|
||||
const beginIdx = source.indexOf(BEGIN);
|
||||
const endIdx = source.indexOf(END);
|
||||
assert.ok(beginIdx !== -1, "BEGIN marker not found");
|
||||
assert.ok(endIdx !== -1, "END marker not found");
|
||||
assert.ok(endIdx > beginIdx, "END marker must come after BEGIN marker");
|
||||
|
||||
// The block strictly between the two marker lines. Move past the end of the
|
||||
// BEGIN marker line so the marker comment text itself is not scanned.
|
||||
const afterBeginLine = source.indexOf("\n", beginIdx) + 1;
|
||||
const block = source.slice(afterBeginLine, endIdx);
|
||||
|
||||
// Detect any real async yield keyword as a whole word. `\bawait\b` also matches
|
||||
// inside `for await`, which is exactly what we want to forbid here.
|
||||
const forbidden = [/\bawait\b/, /\byield\b/];
|
||||
for (const re of forbidden) {
|
||||
const m = block.match(re);
|
||||
assert.equal(
|
||||
m,
|
||||
null,
|
||||
`forbidden async boundary '${m?.[0]}' found inside the no-await critical ` +
|
||||
`window of CollabSession.mutate. INVARIANT 1 (#449): the block between ` +
|
||||
`TiptapTransformer.fromYdoc and applyDocToFragment must be fully ` +
|
||||
`synchronous — an await there reopens the clobber-live-edits race (#152).`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the critical window still spans fromYdoc -> applyDocToFragment", () => {
|
||||
// Guards the markers from drifting off the code they are meant to protect: if
|
||||
// someone moves the read/write out of the window, this catches it.
|
||||
const beginIdx = source.indexOf(BEGIN);
|
||||
const endIdx = source.indexOf(END);
|
||||
const afterBeginLine = source.indexOf("\n", beginIdx) + 1;
|
||||
const block = source.slice(afterBeginLine, endIdx);
|
||||
assert.ok(
|
||||
block.includes("TiptapTransformer.fromYdoc"),
|
||||
"critical window must contain the TiptapTransformer.fromYdoc read",
|
||||
);
|
||||
assert.ok(
|
||||
block.includes("applyDocToFragment"),
|
||||
"critical window must contain the applyDocToFragment write",
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,26 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { withPageLock } from "../../build/lib/page-lock.js";
|
||||
import { withPageLock, isUuid } from "../../build/lib/page-lock.js";
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// withPageLock now asserts its key is a canonical UUID (#449, "resolve-then-
|
||||
// lock"), so the mechanics tests below must lock under real UUIDs, not arbitrary
|
||||
// labels. Distinct valid UUIDv7-shaped ids for the distinct-page cases.
|
||||
const U = {
|
||||
same: "11111111-1111-7111-8111-111111111111",
|
||||
ordered: "22222222-2222-7222-8222-222222222222",
|
||||
poison: "33333333-3333-7333-8333-333333333333",
|
||||
poison2: "44444444-4444-7444-8444-444444444444",
|
||||
A: "aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa",
|
||||
B: "bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb",
|
||||
leak: "55555555-5555-7555-8555-555555555555",
|
||||
};
|
||||
|
||||
test("two ops on the same pageId run strictly sequentially (no overlap)", async () => {
|
||||
const events = [];
|
||||
const pageId = "same-page";
|
||||
const pageId = U.same;
|
||||
|
||||
const p1 = withPageLock(pageId, async () => {
|
||||
events.push("start-1");
|
||||
@@ -33,7 +46,7 @@ test("two ops on the same pageId run strictly sequentially (no overlap)", async
|
||||
});
|
||||
|
||||
test("same pageId ordering holds for many queued ops", async () => {
|
||||
const pageId = "ordered-page";
|
||||
const pageId = U.ordered;
|
||||
const order = [];
|
||||
const active = { count: 0, maxConcurrent: 0 };
|
||||
|
||||
@@ -60,7 +73,7 @@ test("same pageId ordering holds for many queued ops", async () => {
|
||||
});
|
||||
|
||||
test("a rejecting op does not poison the chain for the same page", async () => {
|
||||
const pageId = "poison-page";
|
||||
const pageId = U.poison;
|
||||
const events = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
@@ -87,7 +100,7 @@ test("a rejecting op does not poison the chain for the same page", async () => {
|
||||
});
|
||||
|
||||
test("failing op queued before a success both resolve/reject correctly", async () => {
|
||||
const pageId = "poison-page-2";
|
||||
const pageId = U.poison2;
|
||||
const order = [];
|
||||
|
||||
const failing = withPageLock(pageId, async () => {
|
||||
@@ -111,14 +124,14 @@ test("failing op queued before a success both resolve/reject correctly", async (
|
||||
test("ops on different pageIds run concurrently (overlap)", async () => {
|
||||
const events = [];
|
||||
|
||||
const pA = withPageLock("page-A", async () => {
|
||||
const pA = withPageLock(U.A, async () => {
|
||||
events.push("A-start");
|
||||
await delay(40);
|
||||
events.push("A-end");
|
||||
return "A";
|
||||
});
|
||||
|
||||
const pB = withPageLock("page-B", async () => {
|
||||
const pB = withPageLock(U.B, async () => {
|
||||
events.push("B-start");
|
||||
await delay(10);
|
||||
events.push("B-end");
|
||||
@@ -134,7 +147,7 @@ test("ops on different pageIds run concurrently (overlap)", async () => {
|
||||
});
|
||||
|
||||
test("no functional leak: many sequential ops on same page keep working", async () => {
|
||||
const pageId = "leak-page";
|
||||
const pageId = U.leak;
|
||||
|
||||
// Run a long series of fully sequential ops (each awaited before the next is
|
||||
// queued) so the internal map entry is created and dropped repeatedly.
|
||||
@@ -151,3 +164,56 @@ test("no functional leak: many sequential ops on same page keep working", async
|
||||
const final = await withPageLock(pageId, async () => "still-works");
|
||||
assert.equal(final, "still-works");
|
||||
});
|
||||
|
||||
// --- Issue #449: fail-fast on a non-canonical lock key ---------------------
|
||||
// A write method that reaches the lock path with an unresolved slugId (or any
|
||||
// non-UUID key) must fail IMMEDIATELY and LOUDLY, not lock under a split key and
|
||||
// silently lose per-page serialization. These assert withPageLock rejects such
|
||||
// a key before ever running fn.
|
||||
|
||||
test("withPageLock throws on a raw 10-char slugId (unresolved key)", () => {
|
||||
let ran = false;
|
||||
assert.throws(
|
||||
() =>
|
||||
withPageLock("p7Xk29Lm4Q", async () => {
|
||||
ran = true;
|
||||
return "should-not-run";
|
||||
}),
|
||||
/canonical page UUID|resolve-then-lock/i,
|
||||
"a slugId key must fail-fast at the lock",
|
||||
);
|
||||
// The work must NOT have started: fail-fast means no serialization was
|
||||
// silently skipped under a bad key.
|
||||
assert.equal(ran, false, "fn must not run when the key is rejected");
|
||||
});
|
||||
|
||||
test("withPageLock throws on other non-UUID keys (label, empty, non-string)", () => {
|
||||
for (const bad of ["same-page", "", "not-a-uuid", "1234"]) {
|
||||
assert.throws(
|
||||
() => withPageLock(bad, async () => "x"),
|
||||
/canonical page UUID/i,
|
||||
`expected withPageLock to reject key ${JSON.stringify(bad)}`,
|
||||
);
|
||||
}
|
||||
// A non-string key is also rejected (guards a mistyped call site).
|
||||
assert.throws(
|
||||
() => withPageLock(/** @type {any} */ (undefined), async () => "x"),
|
||||
/canonical page UUID/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("withPageLock accepts a canonical UUID key (no false positive)", async () => {
|
||||
const uuid = "0192f3a4-b5c6-7d8e-9f01-23456789abcd";
|
||||
assert.equal(isUuid(uuid), true);
|
||||
const r = await withPageLock(uuid, async () => "ok");
|
||||
assert.equal(r, "ok");
|
||||
});
|
||||
|
||||
test("isUuid discriminates UUIDs from slugIds (shared predicate)", () => {
|
||||
// The predicate withPageLock asserts on is the SAME one resolvePageId uses to
|
||||
// decide whether a pageId is already a UUID (imported from page-lock).
|
||||
assert.equal(isUuid("0192f3a4-b5c6-7d8e-9f01-23456789abcd"), true);
|
||||
assert.equal(isUuid("p7Xk29Lm4Q"), false); // 10-char nanoid slugId
|
||||
assert.equal(isUuid("not-a-uuid"), false);
|
||||
assert.equal(isUuid(""), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user