diff --git a/apps/server/src/collaboration/extensions/persistence-store.spec.ts b/apps/server/src/collaboration/extensions/persistence-store.spec.ts index c6ed09eb..9aa811f2 100644 --- a/apps/server/src/collaboration/extensions/persistence-store.spec.ts +++ b/apps/server/src/collaboration/extensions/persistence-store.spec.ts @@ -736,6 +736,53 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot' expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID); expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG); }); + + // #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls + // save_page_version on a blank page): the version tx short-circuits with + // nothing to pin. The handler MUST still broadcast a TERMINAL reply + // (version.skipped, reason:'empty') so the client resolves at once instead of + // waiting out its 20s ack timeout and misreporting a healthy server as + // unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal + // reply is sent → this reddens. + it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => { + const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] }; + const document = ydocFor(emptyDoc); + pageRepo.findById.mockResolvedValue({ + ...persistedHumanPage('IGNORED'), + content: emptyDoc, + }); + + await emitSave(document, 'agent'); + + // Nothing pinned. + expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled(); + expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled(); + // But a terminal reply WAS sent so the client never times out. The flush + // (onStoreDocument) emits its own `page.updated`; the version.skipped is the + // LAST broadcast (dropping the skip branch leaves page.updated last → reds). + const calls = (document as any).broadcastStateless.mock.calls; + const msg = JSON.parse(calls[calls.length - 1][0]); + expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' }); + }); + + // #370 F2 — the page row is gone (deleted / never persisted). Same rule: a + // terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the + // client surfaces a truthful "not found" immediately rather than a health + // timeout. onStoreDocument's own `!page` guard returns early without throwing, + // so the handler reaches the version tx and its `!page` skip branch. + it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => { + const document = ydocFor(doc('GONE')); + pageRepo.findById.mockResolvedValue(null); + + await emitSave(document, 'agent'); + + expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled(); + expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1); + const msg = JSON.parse( + (document as any).broadcastStateless.mock.calls[0][0], + ); + expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' }); + }); }); // #370 — the in-memory idle-burst marker must be dropped on doc unload (like diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts index 49a9fd9e..888a4242 100644 --- a/apps/server/src/collaboration/extensions/persistence.extension.ts +++ b/apps/server/src/collaboration/extensions/persistence.extension.ts @@ -68,6 +68,19 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear'; */ export const SAVE_VERSION_MESSAGE_TYPE = 'save-version'; +/** + * #370 F2 — wire format of the server→client REPLY to a save-version signal, sent + * over the same stateless channel. `version.saved` means a version was created or + * promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two + * reachable no-op cases (an effectively-empty page, or the page row is gone) so the + * client resolves at once instead of waiting out its ack timeout and misreporting a + * healthy server as unreachable. EXACTLY ONE of these is broadcast per handled + * save. The MCP client duplicates these literals (it cannot import server code) — + * keep the two in sync (see packages/mcp/src/lib/collaboration.ts). + */ +export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved'; +export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped'; + /** * #251 — how long an intentional-clear signal stays "pending" before it is * ignored. The signal is set on the clearing keystroke but consumed by the @@ -643,6 +656,10 @@ export class PersistenceExtension implements Extension { let result: | { historyId: string; kind: PageHistoryKind; alreadySaved: boolean } | undefined; + // #370 F2 — set when there is nothing to version (empty page / page gone), so + // the tail broadcasts a terminal `version.skipped` instead of staying silent + // and forcing the client to time out. Mutually exclusive with `result`. + let skipped: 'empty' | 'page-not-found' | undefined; // #370 F8-twin — the contributor set popped from Redis (destructive SPOP) // must be restored if the version row does not durably land. The inner @@ -668,11 +685,20 @@ export class PersistenceExtension implements Extension { includeContent: true, trx, }); - if (!page) return; + if (!page) { + // The page row is gone (deleted/never persisted). Nothing to version — + // record it so the tail sends a terminal skip reply (#370 F2). + skipped = 'page-not-found'; + return; + } versionedPageId = page.id; // Never version an effectively-empty page (mirrors the processor's - // first-history guard); there is nothing intentional to pin. - if (isEmptyParagraphDoc(page.content as any)) return; + // first-history guard); there is nothing intentional to pin. Record the + // skip so the client gets a terminal reply rather than a timeout (#370 F2). + if (isEmptyParagraphDoc(page.content as any)) { + skipped = 'empty'; + return; + } const lastHistory = await this.pageHistoryRepo.findPageLastHistory( page.id, @@ -747,15 +773,26 @@ export class PersistenceExtension implements Extension { } this.idleBurstStart.delete(documentName); + // EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote + // broadcasts `version.saved`; the two no-op early returns (empty page / page + // gone) broadcast `version.skipped` so the client never waits out its ack + // timeout. A genuine failure threw above and rejected before reaching here. if (result) { document.broadcastStateless( JSON.stringify({ - type: 'version.saved', + type: VERSION_SAVED_MESSAGE_TYPE, historyId: result.historyId, kind: result.kind, alreadySaved: result.alreadySaved, }), ); + } else if (skipped) { + document.broadcastStateless( + JSON.stringify({ + type: VERSION_SKIPPED_MESSAGE_TYPE, + reason: skipped, + }), + ); } } diff --git a/packages/mcp/src/client/pages.ts b/packages/mcp/src/client/pages.ts index 47cc7257..ba4871ef 100644 --- a/packages/mcp/src/client/pages.ts +++ b/packages/mcp/src/client/pages.ts @@ -592,8 +592,10 @@ export function PagesMixin>(Bas /** * Save an intentional NAMED version of a page's CURRENT live content (#370). * The write goes over the same agent-authenticated collab session content edits - * use, so the server derives kind='agent' from the signed actor. Returns the - * created (or promoted) history id, its kind, and whether it was already saved. + * use, so the server derives kind='agent' from the signed actor. Resolves a + * SaveVersionResult: `{ saved:true, historyId, kind, alreadySaved }` on success, + * or `{ saved:false, skipped:true, reason }` when the server had nothing to pin + * (e.g. an empty page). A stale/missing pageId throws (not a benign skip). */ async savePageVersion(pageId: string) { const collabToken = await this.getCollabTokenWithReauth(); diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 3e7513bf..aa4592de 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -328,25 +328,70 @@ export async function mutatePageContent( * Stateless-channel message types for the #370 explicit save-version handshake. * These MUST match the server constants in * apps/server/src/collaboration/extensions/persistence.extension.ts - * (SAVE_VERSION_MESSAGE_TYPE / the 'version.saved' broadcast) — the mcp package - * cannot import server code, so the literals are duplicated here with this note. + * (SAVE_VERSION_MESSAGE_TYPE and the VERSION_SAVED / VERSION_SKIPPED replies) — + * the mcp package cannot import server code, so the literals are duplicated here. + * KEEP THE TWO SIDES IN SYNC: the server broadcasts exactly ONE terminal reply per + * handled save (`version.saved` for a real save/promote, `version.skipped` for a + * reachable no-op — an empty page or a missing page row); the client must match + * BOTH or it waits out the ack timeout on the skip case and misreports a healthy + * server as unreachable (#370 F2). */ const SAVE_VERSION_MESSAGE_TYPE = "save-version"; const VERSION_SAVED_MESSAGE_TYPE = "version.saved"; +const VERSION_SKIPPED_MESSAGE_TYPE = "version.skipped"; /** - * Bounded wait for the server's `version.saved` broadcast after we ask it to save - * a version. The server flushes the live ydoc through its store path and writes a - * history row inside a DB transaction before broadcasting, so allow the same - * headroom as a persistence ack; reject (do NOT hang) past it. + * Bounded wait for the server's terminal reply after we ask it to save a version. + * The server flushes the live ydoc through its store path and writes a history row + * inside a DB transaction before broadcasting, so allow the same headroom as a + * persistence ack; reject (do NOT hang) past it. A timeout here means NO reply at + * all arrived — the genuine "collab server unreachable/overloaded" case — as + * distinct from a `version.skipped` reply, which resolves immediately. */ const SAVE_VERSION_ACK_TIMEOUT_MS = 20000; -/** The resolved shape of an explicit save-version, surfaced to the tool caller. */ +/** + * The resolved outcome of an explicit save-version, surfaced to the tool caller. + * `saved:true` → a version was created or promoted (historyId/kind/alreadySaved + * are present). `saved:false` → the server had nothing to pin (a reachable no-op, + * e.g. an empty page); `skipped` + `reason` explain why. A page-not-found reply is + * NOT represented here — it is surfaced as a thrown error (a bad/stale pageId). + */ export interface SaveVersionResult { - historyId: string; - kind: string; - alreadySaved: boolean; + saved: boolean; + historyId?: string; + kind?: string; + alreadySaved?: boolean; + skipped?: boolean; + reason?: string; +} + +/** + * Parsed terminal reply the predicate hands back to savePageVersionRealtime. Kept + * internal: it carries the page-not-found case (mapped to a thrown error, never a + * returned result) that SaveVersionResult deliberately does not model. + */ +type SaveVersionReply = + | { outcome: "saved"; historyId: string; kind: string; alreadySaved: boolean } + | { outcome: "skipped"; reason: string }; + +/** Match the server's terminal reply (version.saved / version.skipped), ignoring + * any unrelated / cross-page stateless message so the wait is not resolved by + * noise. Returns `undefined` to keep waiting (#370 F1 predicate coverage). */ +function matchSaveVersionReply(message: any): SaveVersionReply | undefined { + if (!message || typeof message !== "object") return undefined; + if (message.type === VERSION_SAVED_MESSAGE_TYPE) { + return { + outcome: "saved", + historyId: String(message.historyId), + kind: String(message.kind), + alreadySaved: !!message.alreadySaved, + }; + } + if (message.type === VERSION_SKIPPED_MESSAGE_TYPE) { + return { outcome: "skipped", reason: String(message.reason ?? "unknown") }; + } + return undefined; } /** @@ -354,17 +399,27 @@ export interface SaveVersionResult { * (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the * content writes use (#400) — so it authenticates with the caller's agent collab * token, and the server derives kind='agent' from that signed actor — then sends a - * `{type:'save-version'}` stateless message and awaits the server's - * `{type:'version.saved', …}` reply on the same channel. + * `{type:'save-version'}` stateless message and awaits the server's terminal reply + * (`version.saved` or `version.skipped`) on the same channel. * * Deliberately does NOT read `pages.content` over REST: the versioned content is * the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would * not yet reflect. The stateless round-trip is what makes the save exact. * - * On any failure the session is destroyed so the next call reconnects fresh, and - * the error propagates. A save is safe to retry — the server promotes-not- - * duplicates an identical latest version — so the caller (and its agent) may - * re-issue it without risking a duplicate heavy history row. + * Outcomes: + * - a real save/promote → `{ saved:true, historyId, kind, alreadySaved }`; + * - the server had nothing to pin (empty page) → `{ saved:false, skipped:true, + * reason:'empty' }` — a clean, immediate no-op, NOT a stall; + * - the page row is gone (a stale/bad pageId) → a thrown error, immediate and + * truthful (not the health-timeout path); + * - no reply within the timeout → a thrown timeout error (the genuine "server + * unreachable/overloaded" case). + * + * On a TRANSPORT failure (timeout / disconnect) the session is destroyed so the + * next call reconnects fresh; a page-not-found is a healthy-connection terminal + * reply, so the session is left cached. A save is safe to retry — the server + * promotes-not-duplicates an identical latest version — so the caller (and its + * agent) may re-issue it without risking a duplicate heavy history row. */ export async function savePageVersionRealtime( pageId: PageId, @@ -373,24 +428,39 @@ export async function savePageVersionRealtime( ): Promise { return withPageLock(pageId, async () => { const session = await acquireCollabSession(pageId, collabToken, baseUrl); + let reply: SaveVersionReply; try { - return await session.sendStatelessAndAwait( + reply = await session.sendStatelessAndAwait( JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }), - (message) => - message && message.type === VERSION_SAVED_MESSAGE_TYPE - ? { - historyId: String(message.historyId), - kind: String(message.kind), - alreadySaved: !!message.alreadySaved, - } - : undefined, + matchSaveVersionReply, SAVE_VERSION_ACK_TIMEOUT_MS, ); } catch (e) { - // Drop the session on any failure so the next call reconnects fresh. + // TRANSPORT failure (no reply within the timeout, or a disconnect): drop the + // session so the next call reconnects fresh. session.destroy("save-version failed"); throw e; } + // A terminal reply arrived over a healthy connection — do NOT destroy the + // session; interpret the outcome. + if (reply.outcome === "skipped") { + if (reply.reason === "page-not-found") { + // A resolved pageId that the collab server no longer holds (deleted, or a + // stale id). Surface it immediately and truthfully, not as a health error. + throw new Error( + `savePageVersion: page ${pageId} was not found on the collaboration ` + + `server (it may have been deleted) — nothing was saved.`, + ); + } + // Reachable benign no-op (e.g. an empty page): a clean result, not a throw. + return { saved: false, skipped: true, reason: reply.reason }; + } + return { + saved: true, + historyId: reply.historyId, + kind: reply.kind, + alreadySaved: reply.alreadySaved, + }; }); } diff --git a/packages/mcp/src/tool-specs.ts b/packages/mcp/src/tool-specs.ts index ea501be8..184fe17b 100644 --- a/packages/mcp/src/tool-specs.ts +++ b/packages/mcp/src/tool-specs.ts @@ -756,7 +756,9 @@ export const SHARED_TOOL_SPECS = { 'version type is derived SERVER-SIDE from your signed agent identity (you ' + 'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' + 'version is promoted/no-op\'d server-side, so a redundant call is harmless ' + - 'and never duplicates a version. Returns { historyId, kind, alreadySaved }.', + 'and never duplicates a version. Returns { saved:true, historyId, kind, ' + + 'alreadySaved } on success, or { saved:false, skipped:true, reason:\'empty\' } ' + + 'when the page had nothing to pin (e.g. it was empty).', tier: 'deferred', catalogLine: 'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).', diff --git a/packages/mcp/test/unit/save-page-version.test.mjs b/packages/mcp/test/unit/save-page-version.test.mjs index 6e15a593..b78fa5f4 100644 --- a/packages/mcp/test/unit/save-page-version.test.mjs +++ b/packages/mcp/test/unit/save-page-version.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { + acquireCollabSession, destroyAllSessions, __setCollabProviderFactory, } from "../../build/lib/collab-session.js"; @@ -11,9 +12,10 @@ import { savePageVersionRealtime } from "../../build/lib/collaboration.js"; // #370 — unit coverage for the MCP client transport of the explicit save-version. // There is no collaboration server in the test env, so a fake HocuspocusProvider // drives the stateless round-trip: it auto-completes the connect/sync handshake on -// a microtask (like the real provider), records sends, and — depending on the -// `reply` opt — either broadcasts a `version.saved` stateless message back or stays -// silent (to exercise the bounded-timeout path). +// a microtask (like the real provider), records sends, and either auto-broadcasts a +// configured reply (`opts.reply`) or lets the test emit stateless messages / +// disconnects by hand (`_emitStateless` / `_disconnect`) to exercise the predicate +// filter, the teardown-mid-wait path, and the timeout. class FakeProvider extends EventEmitter { static instances = []; @@ -21,6 +23,10 @@ class FakeProvider extends EventEmitter { FakeProvider.instances = []; } + static last() { + return FakeProvider.instances[FakeProvider.instances.length - 1]; + } + constructor(config, opts = {}) { super(); this.config = config; @@ -48,7 +54,7 @@ class FakeProvider extends EventEmitter { sendStateless(payload) { this.sent.push(payload); const reply = this.opts.reply; - if (!reply) return; // silent → the caller must hit its timeout + if (!reply) return; // silent → the caller times out, or the test drives it // Broadcast the configured server reply on a microtask, mirroring // document.broadcastStateless landing back on this provider's `stateless` event. queueMicrotask(() => { @@ -56,6 +62,14 @@ class FakeProvider extends EventEmitter { this.emit("stateless", { payload: JSON.stringify(reply) }); }); } + + // --- test drivers --- + _emitStateless(obj) { + this.emit("stateless", { payload: JSON.stringify(obj) }); + } + _disconnect() { + this.config.onDisconnect?.(); + } } const ENV_KEYS = [ @@ -82,7 +96,16 @@ afterEach(() => { } }); -test("resolves with the server's version.saved reply (historyId/kind/alreadySaved)", async () => { +const PAGE_A = "11111111-1111-4111-8111-111111111111"; +const PAGE_B = "22222222-2222-4222-8222-222222222222"; +const PAGE_C = "33333333-3333-4333-8333-333333333333"; +const PAGE_D = "44444444-4444-4444-8444-444444444444"; +const PAGE_E = "55555555-5555-4555-8555-555555555555"; +const PAGE_F = "66666666-6666-4666-8666-666666666666"; + +// --- happy paths (resolve with the server's terminal reply) ----------------- + +test("saved: resolves { saved:true, historyId, kind, alreadySaved }", async () => { __setCollabProviderFactory( (config) => new FakeProvider(config, { @@ -95,26 +118,22 @@ test("resolves with the server's version.saved reply (historyId/kind/alreadySave }), ); - const result = await savePageVersionRealtime( - "11111111-1111-4111-8111-111111111111", - "collab-tok", - "http://host/api", - ); + const result = await savePageVersionRealtime(PAGE_A, "collab-tok", "http://host/api"); assert.deepEqual(result, { + saved: true, historyId: "hist-42", kind: "agent", alreadySaved: false, }); // The client actually asked the server to save a version (the stateless send). - const provider = FakeProvider.instances.at(-1); assert.deepEqual( - provider.sent.map((p) => JSON.parse(p)), + FakeProvider.last().sent.map((p) => JSON.parse(p)), [{ type: "save-version" }], ); }); -test("surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => { +test("saved: surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => { __setCollabProviderFactory( (config) => new FakeProvider(config, { @@ -127,24 +146,147 @@ test("surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => { }), ); - const result = await savePageVersionRealtime( - "22222222-2222-4222-8222-222222222222", - "collab-tok", - "http://host/api", - ); + const result = await savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api"); assert.deepEqual(result, { + saved: true, historyId: "hist-7", kind: "manual", alreadySaved: true, }); }); -test("rejects on a bounded timeout when the server never replies", async () => { +// --- F2: terminal SKIP replies (no timeout, no false "unreachable") --------- + +test("F2 skip: an empty-page reply resolves to a clean { saved:false, skipped:true, reason:'empty' } — no timeout", async () => { + __setCollabProviderFactory( + (config) => + new FakeProvider(config, { + reply: { type: "version.skipped", reason: "empty" }, + }), + ); + + const result = await savePageVersionRealtime(PAGE_C, "collab-tok", "http://host/api"); + + assert.deepEqual(result, { saved: false, skipped: true, reason: "empty" }); +}); + +test("F2 skip: a page-not-found reply throws an immediate, truthful error (not the health-timeout path)", async () => { + __setCollabProviderFactory( + (config) => + new FakeProvider(config, { + reply: { type: "version.skipped", reason: "page-not-found" }, + }), + ); + + await assert.rejects( + savePageVersionRealtime(PAGE_D, "collab-tok", "http://host/api"), + /was not found on the collaboration server/, + ); +}); + +// --- F1 (a): predicate filters unrelated messages, resolves on the real reply --- + +test("F1(a) unrelated-then-real: an unrelated stateless message does NOT resolve; the later version.saved does", async () => { + __setCollabProviderFactory((config) => new FakeProvider(config, {})); // manual drive + + const p = savePageVersionRealtime(PAGE_E, "collab-tok", "http://host/api"); + // Let acquire's handshake + the stateless send complete so the listener is armed. + await new Promise((r) => setImmediate(r)); + const provider = FakeProvider.last(); + + // Noise first: an unrelated stateless type (e.g. another feature's broadcast). + provider._emitStateless({ type: "something-else", historyId: "NOPE" }); + // The wait must still be pending — a microtask turn would have resolved it if the + // predicate wrongly matched. + let settledEarly = false; + p.then(() => (settledEarly = true)).catch(() => (settledEarly = true)); + await new Promise((r) => setImmediate(r)); + assert.equal(settledEarly, false, "resolved on an unrelated stateless message"); + + // Now the real reply arrives → it resolves with that payload. + provider._emitStateless({ + type: "version.saved", + historyId: "h-real", + kind: "agent", + alreadySaved: false, + }); + const result = await p; + assert.deepEqual(result, { + saved: true, + historyId: "h-real", + kind: "agent", + alreadySaved: false, + }); +}); + +// --- F1 (b): teardown mid-wait rejects AND removes the stateless listener ----- + +test("F1(b) teardown mid-wait: a disconnect rejects the wait with the connection-loss error AND removes the stateless listener (no leak)", async () => { + __setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent + + const session = await acquireCollabSession(PAGE_F, "collab-tok", "http://host/api"); + const provider = FakeProvider.last(); + + const p = session.sendStatelessAndAwait( + JSON.stringify({ type: "save-version" }), + (m) => (m && m.type === "version.saved" ? m : undefined), + 20000, + ); + // Swallow the eventual rejection so it does not race the assertions below. + p.catch(() => {}); + await new Promise((r) => setImmediate(r)); + + // The wait armed exactly one stateless listener. + assert.equal( + provider.listenerCount("stateless"), + 1, + "the wait must register one stateless listener", + ); + + // A disconnect tears the session down mid-wait. + provider._disconnect(); + + await assert.rejects(p, /connection closed before the update was persisted/); + // The listener was removed on teardown — dropping provider.off() reds this. + assert.equal( + provider.listenerCount("stateless"), + 0, + "the stateless listener leaked after teardown", + ); +}); + +// --- F1 (c): concurrent-in-flight guard fails fast, no cross-wiring ----------- + +test("F1(c) concurrent guard: a second sendStatelessAndAwait while one is in flight fails fast", async () => { + __setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent + + const session = await acquireCollabSession(PAGE_A, "collab-tok", "http://host/api"); + + const first = session.sendStatelessAndAwait( + JSON.stringify({ type: "save-version" }), + (m) => (m && m.type === "version.saved" ? m : undefined), + 20000, + ); + first.catch(() => {}); // it stays pending until afterEach destroys the session + + await assert.rejects( + session.sendStatelessAndAwait( + JSON.stringify({ type: "save-version" }), + (m) => (m && m.type === "version.saved" ? m : undefined), + 20000, + ), + /already in-flight/, + ); +}); + +// --- timeout: NO reply at all is the genuine "server unreachable" path -------- + +test("timeout: rejects on a bounded timeout when the server never replies", async () => { __setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent mock.timers.enable({ apis: ["setTimeout"] }); - const p = savePageVersionRealtime("33333333-3333-4333-8333-333333333333", "collab-tok", "http://host/api"); + const p = savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api"); // Swallow the eventual rejection so an unhandled-rejection does not race the tick. p.catch(() => {});