fix(#370): F2 terminal skip reply for empty/missing page + F1 lifecycle tests

Reviewer changes-requested on #565.

F2 (real bug): a save on an empty page (or a missing page row) hung the client
for the full 20s ack window and then falsely reported the collab server as
unreachable + advised retry. Root cause: handleSaveVersion gated its
broadcastStateless on `if (result)`, and `result` stays undefined on the two
reachable early returns (isEmptyParagraphDoc and !page), so the server sent
NOTHING and the client waited out its timeout.
- SERVER (persistence.extension.ts): both early-return branches now record a
  skip reason and the tail broadcasts a terminal `version.skipped` reply
  (reason 'empty' | 'page-not-found'). Exactly one terminal reply per handled
  save. Added VERSION_SAVED/VERSION_SKIPPED message consts.
- CLIENT (collaboration.ts): the predicate now matches both version.saved and
  version.skipped; SaveVersionResult gains {saved, skipped, reason}. An empty
  skip resolves to a clean {saved:false, skipped:true, reason:'empty'} (no
  stall); page-not-found throws an immediate, truthful error; the genuine
  no-reply timeout path is unchanged (that is the real "unreachable" case). A
  terminal reply leaves the session cached (healthy connection); only a
  transport failure destroys it. Kept the message-literal in-sync comments.
- tool-specs.ts / pages.ts: description + docstring reflect the new result shape.

F1 (coverage): added tests for the 4 previously-uncovered sendStatelessAndAwait
branches — unrelated-then-real (predicate filters noise), teardown-mid-wait
(rejects + removes the stateless listener, no leak), concurrent-in-flight
guard — plus the F2 empty-skip / page-not-found client outcomes. Server spec
gains empty-page and page-not-found terminal-reply tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 19:12:42 +03:00
parent 498a87f3c4
commit 1644de87f7
6 changed files with 353 additions and 53 deletions
@@ -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
@@ -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,
}),
);
}
}
+4 -2
View File
@@ -592,8 +592,10 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(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();
+96 -26
View File
@@ -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<SaveVersionResult> {
return withPageLock(pageId, async () => {
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
let reply: SaveVersionReply;
try {
return await session.sendStatelessAndAwait<SaveVersionResult>(
reply = await session.sendStatelessAndAwait<SaveVersionReply>(
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,
};
});
}
+3 -1
View File
@@ -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).',
+162 -20
View File
@@ -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(() => {});