diff --git a/apps/cli/src/commands/close.ts b/apps/cli/src/commands/close.ts index 7c49f7a8ce..fc55165f98 100644 --- a/apps/cli/src/commands/close.ts +++ b/apps/cli/src/commands/close.ts @@ -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.', diff --git a/apps/cli/src/host/session-pool.ts b/apps/cli/src/host/session-pool.ts index 7160ad8127..565af89316 100644 --- a/apps/cli/src/host/session-pool.ts +++ b/apps/cli/src/host/session-pool.ts @@ -390,7 +390,7 @@ export class InMemorySessionPool implements SessionPool { private async destroySession(session: PooledSession): Promise { this.clearAutosaveTimer(session); - session.opened.dispose(); + await session.opened.dispose(); } // ------------------------------------------------------------------------- diff --git a/apps/cli/src/lib/collaboration/liveblocks.ts b/apps/cli/src/lib/collaboration/liveblocks.ts index f528357536..5bb20d7a6e 100644 --- a/apps/cli/src/lib/collaboration/liveblocks.ts +++ b/apps/cli/src/lib/collaboration/liveblocks.ts @@ -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(); diff --git a/apps/cli/src/lib/collaboration/runtime.ts b/apps/cli/src/lib/collaboration/runtime.ts index e37277efe6..b4c9300f89 100644 --- a/apps/cli/src/lib/collaboration/runtime.ts +++ b/apps/cli/src/lib/collaboration/runtime.ts @@ -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 @@ -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 { + return new Promise((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 // --------------------------------------------------------------------------- @@ -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?.(); diff --git a/apps/cli/src/lib/collaboration/types.ts b/apps/cli/src/lib/collaboration/types.ts index 79c2e7eda4..40ae10b889 100644 --- a/apps/cli/src/lib/collaboration/types.ts +++ b/apps/cli/src/lib/collaboration/types.ts @@ -75,7 +75,10 @@ export type CollaborationSummary = { export type CollaborationRuntime = { ydoc: YDoc; provider: unknown; - waitForSync(): Promise; + /** Wait for initial sync with server on connect. */ + waitForInitialSync(): Promise; + /** Wait for pending updates to be sent before disconnect. */ + waitForFinalFlush(): Promise; dispose(): void; }; diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index cd2d193d70..4f3a67a337 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -40,7 +40,7 @@ export type EditorWithDoc = Editor & { export interface OpenedDocument { editor: EditorWithDoc; meta: DocumentSourceMeta; - dispose(): void; + dispose(): void | Promise; } /** Content override options extracted before calling Editor.open(). */ @@ -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 @@ -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) { diff --git a/demos/collaborative-agent/test-close-sync.ts b/demos/collaborative-agent/test-close-sync.ts new file mode 100644 index 0000000000..7c7339d00b --- /dev/null +++ b/demos/collaborative-agent/test-close-sync.ts @@ -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); +});