Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/cli/src/commands/close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export async function runClose(tokens: string[], context: CommandContext): Promi
async ({ metadata, paths }) => {
const effectiveMetadata = metadata;

if (effectiveMetadata.dirty && !mode.discard) {
// In collab-only sessions (no source path), "dirty" means not saved to a
// local file — but changes ARE persisted to the collaboration server.
// Allow close() without --discard since waitForFinalFlush ensures sync.
const isCollabOnly = effectiveMetadata.sessionType === 'collab' && !effectiveMetadata.sourcePath;
if (effectiveMetadata.dirty && !mode.discard && !isCollabOnly) {
throw new CliError(
'DIRTY_CLOSE_REQUIRES_DECISION',
'Active document has unsaved changes. Run "superdoc save" first or close with --discard.',
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/host/session-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ export class InMemorySessionPool implements SessionPool {

private async destroySession(session: PooledSession): Promise<void> {
this.clearAutosaveTimer(session);
session.opened.dispose();
await session.opened.dispose();
}

// -------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion apps/cli/src/lib/collaboration/liveblocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ export function createLiveblocksRuntime(profile: LiveblocksCollaborationProfile)
return {
ydoc,
provider,
waitForSync: () => waitForLiveblocksSync(provider, room, syncTimeoutMs),
waitForInitialSync: () => waitForLiveblocksSync(provider, room, syncTimeoutMs),
// Liveblocks handles sync internally; no websocket buffer to flush.
waitForFinalFlush: () => Promise.resolve(),
dispose() {
// Order matters: unsubscribe → provider.destroy → leave → ydoc.destroy
provider.destroy();
Expand Down
41 changes: 40 additions & 1 deletion apps/cli/src/lib/collaboration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {

export const DEFAULT_SYNC_TIMEOUT_MS = 10_000;
const SYNC_POLL_INTERVAL_MS = 25;
const FLUSH_POLL_INTERVAL_MS = 10;
const FLUSH_TIMEOUT_MS = 5_000;

// ---------------------------------------------------------------------------
// Websocket sync helper
Expand Down Expand Up @@ -84,6 +86,42 @@ export function waitForProviderSync(
});
}

// ---------------------------------------------------------------------------
// Websocket buffer flush helper
// ---------------------------------------------------------------------------

type ProviderWithWs = { ws?: { bufferedAmount?: number; readyState?: number } | null };

/**
* Wait for the websocket send buffer to be empty.
* This ensures pending Y.js updates have been handed to the network layer.
*/
function waitForBufferFlush(provider: unknown, timeoutMs: number = FLUSH_TIMEOUT_MS): Promise<void> {
return new Promise<void>((resolve) => {
const startedAt = Date.now();

const check = () => {
const ws = (provider as ProviderWithWs).ws;
const buffered = ws?.bufferedAmount ?? 0;
const readyState = ws?.readyState;

// Resolve if: no websocket, websocket closed, or buffer empty
if (!ws || readyState !== 1 || buffered === 0) {
resolve();
return;
}
if (Date.now() - startedAt > timeoutMs) {
// Timeout - resolve anyway to avoid blocking forever
resolve();
return;
}
setTimeout(check, FLUSH_POLL_INTERVAL_MS);
};

check();
});
}

// ---------------------------------------------------------------------------
// Websocket runtime factories
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -132,7 +170,8 @@ function createWebSocketRuntime(profile: WebSocketCollaborationProfile): Collabo
return {
ydoc,
provider,
waitForSync: () => waitForProviderSync(provider, syncTimeoutMs, diagnostics),
waitForInitialSync: () => waitForProviderSync(provider, syncTimeoutMs, diagnostics),
waitForFinalFlush: () => waitForBufferFlush(provider),
dispose() {
diagnostics?.detach();
provider.disconnect?.();
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/src/lib/collaboration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ export type CollaborationSummary = {
export type CollaborationRuntime = {
ydoc: YDoc;
provider: unknown;
waitForSync(): Promise<void>;
/** Wait for initial sync with server on connect. */
waitForInitialSync(): Promise<void>;
/** Wait for pending updates to be sent before disconnect. */
waitForFinalFlush(): Promise<void>;
dispose(): void;
};

Expand Down
15 changes: 7 additions & 8 deletions apps/cli/src/lib/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type EditorWithDoc = Editor & {
export interface OpenedDocument {
editor: EditorWithDoc;
meta: DocumentSourceMeta;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Await async document disposal at call sites

Changing OpenedDocument.dispose() to optionally return a promise makes collaborative one-shot/session cleanup asynchronous, but most callers still invoke it without await (for example mutation-orchestrator.ts and save.ts finally blocks). In those non-host collaborative commands, openCollaborativeDocument.dispose() now starts runtime.waitForSync() and returns immediately to the CLI, so the process can finish before the Y.js sync completes and before the provider is disconnected; the intended “wait before disconnecting” behavior only works in the session-pool path that was updated.

Useful? React with 👍 / 👎.

dispose(): void;
dispose(): void | Promise<void>;
}

/** Content override options extracted before calling Editor.open(). */
Expand Down Expand Up @@ -312,7 +312,7 @@ export async function openCollaborativeDocument(
const runtime = createCollaborationRuntime(profile);

try {
await runtime.waitForSync();
await runtime.waitForInitialSync();

// SD-2138: Some providers fire "synced" before Yjs updates are fully
// applied to local shared types. Give a brief window for the XmlFragment
Expand Down Expand Up @@ -381,12 +381,11 @@ export async function openCollaborativeDocument(
editor: opened.editor,
meta: opened.meta,
bootstrap,
dispose() {
try {
opened.dispose();
} finally {
runtime.dispose();
}
async dispose() {
// Wait for pending Y.js updates to be sent before disconnecting.
await runtime.waitForFinalFlush();
opened.dispose();
runtime.dispose();
},
};
} catch (error) {
Expand Down
104 changes: 104 additions & 0 deletions demos/collaborative-agent/test-close-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Test script for IT-1142: verify edits persist after close in collab-only mode.
*
* Usage:
* 1. Start y-websocket server: HOST=0.0.0.0 PORT=8082 npx y-websocket
* 2. Run this script: npx tsx test-close-sync.ts
*/

import { SuperDocClient } from '@superdoc-dev/sdk';

const COLLAB_URL = 'ws://localhost:8082';
const ROOM_ID = `test-close-sync-${Date.now()}`;

async function main() {
console.log('=== Test: Close waits for Y.js sync ===\n');
console.log(`Room: ${ROOM_ID}`);
console.log(`Collab URL: ${COLLAB_URL}\n`);

// --- Session 1: Make an edit and close immediately ---
console.log('1. Opening first session...');
const client1 = new SuperDocClient({
startupTimeoutMs: 15_000,
// Use local dev CLI for testing
env: { SUPERDOC_CLI_BIN: './dev-cli.sh' },
});
await client1.connect();

const doc1 = await client1.open({
collaboration: {
providerType: 'y-websocket',
url: COLLAB_URL,
documentId: ROOM_ID,
syncTimeoutMs: 10_000,
},
});
console.log(` Session opened: ${doc1.sessionId}`);

// Make an edit using create.paragraph
const testText = `Edit made at ${new Date().toISOString()}`;
console.log(`2. Creating paragraph with text: "${testText}"`);

try {
const result = await doc1.create.paragraph({
text: testText,
at: { kind: 'documentStart' },
});
console.log(` Create result: success=${result?.receipt?.success}`);
} catch (e: any) {
console.log(` Create failed: ${e.message}`);
}

// Test close() WITHOUT discard - this used to throw "no source path"
console.log('3. Closing with close() (no discard flag)...');
try {
await doc1.close(); // Should work now for collab-only sessions
console.log(' ✅ close() succeeded!');
} catch (e: any) {
console.log(` ❌ close() failed: ${e.message}`);
}
await client1.dispose();
console.log(' Client disposed\n');

// --- Session 2: Reconnect and verify ---
console.log('4. Opening second session to verify...');
const client2 = new SuperDocClient({ startupTimeoutMs: 15_000 });
await client2.connect();

const doc2 = await client2.open({
collaboration: {
providerType: 'y-websocket',
url: COLLAB_URL,
documentId: ROOM_ID,
syncTimeoutMs: 10_000,
},
});
console.log(` Session opened: ${doc2.sessionId}`);

// Read the content
console.log('5. Reading document content...');
try {
const text = await doc2.getText({});
console.log(` Text length: ${text?.length ?? 0}`);
console.log(` Text preview: "${text?.substring(0, 100) ?? '(empty)'}"`);

if (text?.includes(testText)) {
console.log('\n✅ SUCCESS: Edit persisted after close!');
} else {
console.log('\n❌ FAILURE: Edit was lost!');
console.log(` Expected to find: "${testText}"`);
}
} catch (e: any) {
console.log(` Read failed: ${e.message}`);
}

// Cleanup
await doc2.close({ discard: true }).catch(() => {});
await client2.dispose();
console.log('\nDone.');
}

main().catch((e) => {
console.error('Fatal error:', e);
process.exit(1);
});
Loading