From 3610590ad79b99330db840f8bee0a6e4b6e16800 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 8 May 2026 21:20:16 +0800 Subject: [PATCH 1/2] fix: enhance connection resilience and fix TCP RST causing spurious connection lost (fixes #309) Root cause: socket.io init() created new TCP connections without disconnecting old ones, causing orphaned connections. The OS TCP stack responded with RST when the server sent late/out-of-order data on abandoned connections, which could cause the server to drop ALL connections from this client. Changes by file: - src/api/base.ts: - Enable socket.io auto-reconnect (reconnect: true, delay 1s-16s, 10 attempts) instead of manual socket recreation on every transient hiccup - Add HTTP request retry (up to 2x) for transient errors (5xx, ECONNRESET, ETIMEDOUT, ECONNREFUSED, ENOTFOUND) - src/api/socketio.ts: - Properly disconnect old socket (removeAllListeners + disconnect) before creating new one to send FIN instead of RST - Disable auto-reconnect on connectionRejected to prevent futile reconnect->reject->reconnect loops - Fix EventBus listener leak (MaxListenersExceededWarning) by tracking Disposables and cleaning up before reinit - Add v2->v1 fallback when both schemes rejected - Replace unsafe throw with console.error on socket error events - Track _socketInitScheme; expose needsReinit getter - src/core/remoteFileSystemProvider.ts: - Exponential backoff: 3->5 retries, 0s->1s/2s/4s/8s/16s delays - Only recreate socket on scheme change (needsReinit), not on every retry; let socket.io auto-reconnect handle transient disconnects - 2s disconnect debounce to ignore rapid connect/disconnect cycles - 1s delay before initiating reconnection after disconnect - Show 'Reconnecting to {server}...' progress notification during retries instead of silent failures - Add 'Retry' button alongside 'Reload' in error dialog - Reset retry state on connectionAccepted - src/collaboration/clientManager.ts: - 15s grace period before clearing collaborator decorations on disconnect; show 'Reconnecting...' spin indicator instead - Only show red 'Not connected' after grace period expires - l10n/bundle.l10n.json: - Add localization strings: Retry, Reconnecting..., Reconnecting to {serverName}..., Connection interrupted... --- l10n/bundle.l10n.json | 4 + src/api/base.ts | 183 +++++++++++++++++-------- src/api/socketio.ts | 67 ++++++++- src/collaboration/clientManager.ts | 118 +++++++++------- src/core/remoteFileSystemProvider.ts | 194 ++++++++++++++++++++------- 5 files changed, 405 insertions(+), 161 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index d31cccf5..7132f679 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -33,7 +33,9 @@ "Cannot init SocketIOAPI for {serverName}": "Cannot init SocketIOAPI for {serverName}", "Connection lost: {serverName}": "Connection lost: {serverName}", "Reload": "Reload", + "Retry": "Retry", "Connection lost": "Connection lost", + "Reconnecting to {serverName}...": "Reconnecting to {serverName}...", "Refreshing": "Refreshing", "Done": "Done", "From Another Project": "From Another Project", @@ -98,6 +100,8 @@ "At {docPath}, Line {row}": "At {docPath}, Line {row}", "Select a collaborator below to jump to.": "Select a collaborator below to jump to.", "Not connected": "Not connected", + "Reconnecting...": "Reconnecting...", + "Connection interrupted, attempting to reconnect...": "Connection interrupted, attempting to reconnect...", "Online": "Online", "Active": "Active", "Idle": "Idle", diff --git a/src/api/base.ts b/src/api/base.ts index 55470237..df81a166 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -243,7 +243,14 @@ export class BaseAPI { _initSocketV0(identity:Identity, query?:string) { const url = new URL(this.url).origin + (query ?? ''); return (require('socket.io-client').connect as any)(url, { - reconnect: false, + // Enable auto-reconnect to avoid creating new TCP connections on transient failures. + // Creating new connections without proper teardown of old ones causes TCP RST packets + // when the server sends data on abandoned connections (see issue #309). + // Note: socket.io-client 0.9.x uses space-separated option names. + reconnect: true, + 'reconnection delay': 1000, + 'reconnection limit': 16000, + 'max reconnection attempts': 10, 'force new connection': true, extraHeaders: { 'Origin': new URL(this.url).origin, @@ -340,68 +347,128 @@ export class BaseAPI { return this; } + /** + * Check if an HTTP error is transient (worth retrying). + * Retries on: 5xx server errors, network errors (fetch failures), and 429 rate limiting. + */ + private isTransientError(statusCode: number | undefined, errorMessage?: string): boolean { + if (statusCode === undefined) { + // Network-level error (DNS, connection refused, reset, timeout) + return true; + } + // Server errors and rate limiting + if (statusCode >= 500 || statusCode === 429) { + return true; + } + // Common transient network error messages + if (errorMessage && ( + errorMessage.includes('ECONNRESET') || + errorMessage.includes('ETIMEDOUT') || + errorMessage.includes('ECONNREFUSED') || + errorMessage.includes('ENOTFOUND') || + errorMessage.includes('socket hang up') + )) { + return true; + } + return false; + } + protected async request(type:'GET'|'POST'|'PUT'|'DELETE', route:string, body?:FormData|object, callback?: (res?:string)=>object|undefined, extraHeaders?:object ): Promise { if (this.identity===undefined) { return Promise.reject(); } - let res = undefined; - switch(type) { - case 'GET': - res = await fetch(this.url+route, { - method: 'GET', redirect: 'manual', agent: this.agent, - headers: { - 'Connection': 'keep-alive', - 'Cookie': this.identity.cookies, - ...extraHeaders - } - }); - break; - case 'POST': - // if body is FormData, then it is a raw body - const content_type = body instanceof FormData ? undefined : {'Content-Type': 'application/json'}; - const raw_body = body instanceof FormData ? body : JSON.stringify({ - _csrf: this.identity.csrfToken, - ...body - }); - res = await fetch(this.url+route, { - method: 'POST', redirect: 'manual', agent: this.agent, - headers: { - 'Connection': 'keep-alive', - 'Cookie': this.identity.cookies, - ...content_type, - ...extraHeaders - }, - body: raw_body - }); - break; - case 'PUT': - break; - case 'DELETE': - res = await fetch(this.url+route, { - method: 'DELETE', redirect: 'manual', agent: this.agent, - headers: { - 'Connection': 'keep-alive', - 'Cookie': this.identity.cookies, - 'X-Csrf-Token': this.identity.csrfToken, - ...extraHeaders - } - }); - break; - }; + const MAX_HTTP_RETRIES = 2; + let lastError: {statusCode?: number, message?: string} = {}; + + for (let attempt = 0; attempt <= MAX_HTTP_RETRIES; attempt++) { + try { + let res = undefined; + switch(type) { + case 'GET': + res = await fetch(this.url+route, { + method: 'GET', redirect: 'manual', agent: this.agent, + headers: { + 'Connection': 'keep-alive', + 'Cookie': this.identity!.cookies, + ...extraHeaders + } + }); + break; + case 'POST': + // if body is FormData, then it is a raw body + const content_type = body instanceof FormData ? undefined : {'Content-Type': 'application/json'}; + const raw_body = body instanceof FormData ? body : JSON.stringify({ + _csrf: this.identity!.csrfToken, + ...body + }); + res = await fetch(this.url+route, { + method: 'POST', redirect: 'manual', agent: this.agent, + headers: { + 'Connection': 'keep-alive', + 'Cookie': this.identity!.cookies, + ...content_type, + ...extraHeaders + }, + body: raw_body + }); + break; + case 'PUT': + break; + case 'DELETE': + res = await fetch(this.url+route, { + method: 'DELETE', redirect: 'manual', agent: this.agent, + headers: { + 'Connection': 'keep-alive', + 'Cookie': this.identity!.cookies, + 'X-Csrf-Token': this.identity!.csrfToken, + ...extraHeaders + } + }); + break; + }; - if (res && (res.status===200 || res.status===204)) { - const _res = res.status===200 ? await res.text() : undefined; - const response = callback && callback(_res); - return { - type: 'success', - ...response - } as ResponseSchema; - } else { - res = res || { status:'undefined', text:()=>'' }; - return { - type: 'error', - message: `${res.status}: `+await res.text() - }; + if (res && (res.status===200 || res.status===204)) { + const _res = res.status===200 ? await res.text() : undefined; + const response = callback && callback(_res); + return { + type: 'success', + ...response + } as ResponseSchema; + } else if (res && this.isTransientError(res.status) && attempt < MAX_HTTP_RETRIES) { + // Transient error: retry with backoff + const delayMs = Math.min(1000 * Math.pow(2, attempt), 4000); + console.log(`HTTP ${res.status} on ${route}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`); + lastError = {statusCode: res.status, message: await res.text().catch(() => '')}; + await new Promise(r => setTimeout(r, delayMs)); + continue; + } else { + const resOrFallback = res || { status:'undefined', text: async () => '' }; + let errorBody = ''; + try { errorBody = await resOrFallback.text(); } catch { errorBody = ''; } + return { + type: 'error', + message: `${resOrFallback.status}: ${errorBody}` + }; + } + } catch (err: any) { + const errMsg = err?.message || String(err); + if (this.isTransientError(undefined, errMsg) && attempt < MAX_HTTP_RETRIES) { + const delayMs = Math.min(1000 * Math.pow(2, attempt), 4000); + console.log(`HTTP fetch error on ${route}: ${errMsg}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`); + await new Promise(r => setTimeout(r, delayMs)); + continue; + } + return { + type: 'error', + message: errMsg + }; + } } + + // All retries exhausted + return { + type: 'error', + message: lastError.message || `Request failed after ${MAX_HTTP_RETRIES + 1} attempts` + }; } protected async download(route:string) { diff --git a/src/api/socketio.ts b/src/api/socketio.ts index ccd2a611..2cfd9e6c 100644 --- a/src/api/socketio.ts +++ b/src/api/socketio.ts @@ -78,9 +78,13 @@ export class SocketIOAPI { private scheme: ConnectionScheme = 'v1'; private record?: Promise; private _handlers: Array = []; + /** Track EventBus listeners for cleanup to prevent MaxListenersExceededWarning */ + private _eventBusCleanups: Array<()=>void> = []; private socket?: any; private emit: any; + /** Track the scheme used when the socket was last initialized */ + private _socketInitScheme?: ConnectionScheme; constructor(private url:string, private readonly api:BaseAPI, @@ -91,6 +95,29 @@ export class SocketIOAPI { } init() { + // Clean up old EventBus listeners before creating new socket + this._cleanupEventBusListeners(); + + // CRITICAL: Properly disconnect old socket before creating a new one. + // Without this, the old TCP connection is abandoned but still alive. When the + // server later sends data on it (out-of-order/late packets), the OS TCP stack + // responds with RST, which can cause the server to drop ALL connections from + // this client — explaining the "connection lost" loop reported in issue #309. + if (this.socket) { + try { + // Remove all listeners to prevent stale event handlers from firing + if (typeof this.socket.removeAllListeners === 'function') { + this.socket.removeAllListeners(); + } + // Gracefully close the connection (sends FIN, not RST) + if (typeof this.socket.disconnect === 'function') { + this.socket.disconnect(); + } + } catch { + // Best-effort cleanup; socket may already be in a bad state + } + } + // connect switch(this.scheme) { case 'Alt': @@ -127,7 +154,23 @@ export class SocketIOAPI { this.emit = require('util').promisify(this.socket.emit).bind(this.socket); // resume handlers this.initInternalHandlers(); - // this.resumeEventHandlers(this._handlers); + // Re-register existing event handlers on the new socket + this.resumeEventHandlers(this._handlers); + // Track which scheme this socket was created with + this._socketInitScheme = this.scheme; + } + + /** Returns true if the socket needs re-initialization (scheme changed, or socket was never init'd) */ + get needsReinit(): boolean { + return this._socketInitScheme !== this.scheme || !this.socket; + } + + /** Clean up any accumulated EventBus listeners */ + private _cleanupEventBusListeners() { + for (const cleanup of this._eventBusCleanups) { + try { cleanup(); } catch {} + } + this._eventBusCleanups = []; } private initInternalHandlers() { @@ -141,10 +184,22 @@ export class SocketIOAPI { console.log('SocketIOAPI: forceDisconnect', message); }); this.socket.on('connectionRejected', (err:any) => { - console.log('SocketIOAPI: connectionRejected.', err.message); + console.log('SocketIOAPI: connectionRejected.', err?.message || err); + // If v2 also gets rejected, fall back to v1 rather than staying stuck + if (this.scheme === 'v2') { + console.log('SocketIOAPI: v2 rejected, falling back to v1'); + this.scheme = 'v1'; + } + // Disable auto-reconnect on this socket: the server explicitly rejected + // our connection parameters. Reconnecting would just get rejected again, + // creating unnecessary TCP connection churn (and RST packets). + if (this.socket.io && typeof this.socket.io.reconnect === 'function') { + this.socket.io.reconnect(false); + } }); this.socket.on('error', (err:any) => { - throw new Error(err); + // Log error instead of throwing to avoid crashing the extension + console.error('SocketIOAPI: socket error', err?.message || err); }); if (this.scheme==='v2') { @@ -230,9 +285,11 @@ export class SocketIOAPI { this.socket.on('connectionAccepted', (_:any, publicId:any) => { handler(publicId); }); - EventBus.on('socketioConnectedEvent', (arg:{publicId:string}) => { + // Track EventBus listener via Disposable for cleanup to prevent MaxListenersExceededWarning + const eventBusDisposable = EventBus.on('socketioConnectedEvent', (arg:{publicId:string}) => { handler(arg.publicId); }); + this._eventBusCleanups.push(() => eventBusDisposable.dispose()); break; case handlers.onClientUpdated: this.socket.on('clientTracking.clientUpdated', (user:UpdateUserSchema) => { @@ -306,6 +363,8 @@ export class SocketIOAPI { }); const rejectPromise = new Promise((_, reject) => { this.socket.on('connectionRejected', (err:any) => { + // Only fall back to v2 if we haven't already tried it; + // otherwise let the outer retry logic handle backoff this.scheme = 'v2'; reject(err.message); }); diff --git a/src/collaboration/clientManager.ts b/src/collaboration/clientManager.ts index c108ca96..15aa3727 100644 --- a/src/collaboration/clientManager.ts +++ b/src/collaboration/clientManager.ts @@ -59,6 +59,10 @@ export class ClientManager { private readonly onlineUsers: {[K:string]:ExtendedUpdateUserSchema} = {}; private connectedFlag: boolean = true; private readonly chatViewer: ChatViewProvider; + /** Timestamp when connection was lost; used for grace period before clearing user list */ + private disconnectedAt: number = 0; + /** Grace period in ms before clearing online user list on disconnect */ + private static readonly DISCONNECT_GRACE_PERIOD_MS = 15000; // 15 seconds constructor( private readonly vfs: VirtualFileSystem, @@ -76,9 +80,11 @@ export class ClientManager { }, onDisconnected: () => { this.connectedFlag = false; + this.disconnectedAt = Date.now(); }, onConnectionAccepted: (publicId:string) => { this.connectedFlag = true; + this.disconnectedAt = 0; } }); this.socket.getConnectedUsers().then(users => { @@ -241,64 +247,78 @@ export class ClientManager { private updateStatus() { const count = Object.keys(this.onlineUsers).length; - switch (this.connectedFlag) { - case false: + if (!this.connectedFlag) { + const disconnectedDuration = Date.now() - this.disconnectedAt; + // Show reconnecting state during grace period + if (disconnectedDuration < ClientManager.DISCONNECT_GRACE_PERIOD_MS) { + this.status.color = new vscode.ThemeColor('statusBarItem.warningBackground'); + this.status.backgroundColor = undefined; + this.status.text = '$(sync~spin) ' + vscode.l10n.t('Reconnecting...'); + this.status.tooltip = `${ELEGANT_NAME}: ${vscode.l10n.t('Connection interrupted, attempting to reconnect...')}`; + this.status.command = `${ROOT_NAME}.collaboration.settings`; + // Keep online user decorations during brief disconnections + } else { + // Grace period expired: show disconnected state this.status.color = 'red'; + this.status.backgroundColor = undefined; this.status.text = '$(sync-ignored)'; this.status.tooltip = `${ELEGANT_NAME}: ${vscode.l10n.t('Not connected')}`; - // Kick out all users indication since the connection is lost + this.status.command = `${ROOT_NAME}.collaboration.settings`; + // Clear online user decorations after grace period Object.keys(this.onlineUsers).forEach(clientId => { this.removePosition(clientId); }); - break; - case true: - let prefixText = ''; - // notify unread messages - if (this.chatViewer.hasUnread) { - prefixText = prefixText.concat(`$(bell-dot) ${this.chatViewer.hasUnread} `); - } - this.status.command = this.chatViewer.hasUnread? `${ROOT_NAME}.collaboration.revealChatView` : `${ROOT_NAME}.collaboration.settings`; - this.status.backgroundColor = this.chatViewer.hasUnread? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined; - // notify unSynced changes - const unSynced = this.socket.unSyncFileChanges; - if (unSynced) { - prefixText = prefixText.concat(`$(arrow-up) ${unSynced} `); - } + } + } else { + // Connected state: clear any stale disconnect timestamp + this.disconnectedAt = 0; + + let prefixText = ''; + // notify unread messages + if (this.chatViewer.hasUnread) { + prefixText = prefixText.concat(`$(bell-dot) ${this.chatViewer.hasUnread} `); + } + this.status.command = this.chatViewer.hasUnread? `${ROOT_NAME}.collaboration.revealChatView` : `${ROOT_NAME}.collaboration.settings`; + this.status.backgroundColor = this.chatViewer.hasUnread? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined; + // notify unSynced changes + const unSynced = this.socket.unSyncFileChanges; + if (unSynced) { + prefixText = prefixText.concat(`$(arrow-up) ${unSynced} `); + } - const isInvisible = this.socket.isUsingAlternativeConnectionScheme; - const onlineIcon = isInvisible ? '$(person)' : '$(organization)'; - switch (count) { - case 0: - this.status.color = undefined; - this.status.text = prefixText + `${onlineIcon} 0`; - this.status.tooltip = `${ELEGANT_NAME}: ${vscode.l10n.t('Online')}`; - break; - default: - this.status.color = this.activeExists ? this.onlineUsers[this.activeExists].selection?.color : undefined; - this.status.text = prefixText + `${onlineIcon} ${count}`; - const tooltip = new vscode.MarkdownString(); - tooltip.appendMarkdown(`${ELEGANT_NAME}: ${this.activeExists? vscode.l10n.t('Active'): vscode.l10n.t('Idle') }\n\n`); + const isInvisible = this.socket.isUsingAlternativeConnectionScheme; + const onlineIcon = isInvisible ? '$(person)' : '$(organization)'; + switch (count) { + case 0: + this.status.color = undefined; + this.status.text = prefixText + `${onlineIcon} 0`; + this.status.tooltip = `${ELEGANT_NAME}: ${vscode.l10n.t('Online')}`; + break; + default: + this.status.color = this.activeExists ? this.onlineUsers[this.activeExists].selection?.color : undefined; + this.status.text = prefixText + `${onlineIcon} ${count}`; + const tooltip = new vscode.MarkdownString(); + tooltip.appendMarkdown(`${ELEGANT_NAME}: ${this.activeExists? vscode.l10n.t('Active'): vscode.l10n.t('Idle') }\n\n`); - Object.values(this.onlineUsers).forEach(user => { - const userArgs = JSON.stringify([`@[[${user.name}#${user.user_id}]] `]); - const userCommandUri = vscode.Uri.parse(`command:${ROOT_NAME}.collaboration.insertText?${encodeURIComponent(userArgs)}`); - const userInfo = `@${user.name}`; + Object.values(this.onlineUsers).forEach(user => { + const userArgs = JSON.stringify([`@[[${user.name}#${user.user_id}]] `]); + const userCommandUri = vscode.Uri.parse(`command:${ROOT_NAME}.collaboration.insertText?${encodeURIComponent(userArgs)}`); + const userInfo = `@${user.name}`; - const jumpArgs = JSON.stringify([user.id]); - const jumpCommandUri = vscode.Uri.parse(`command:${ROOT_NAME}.collaboration.jumpToUser?${encodeURIComponent(jumpArgs)}`); - const docPath = user.doc_id ? this.vfs._resolveById(user.doc_id)?.path.slice(1) : undefined; - const cursorInfo = user.row ? ` at ${docPath}#L${user.row+1}` : ''; - - const since_last_update = user.last_updated_at ? formatTime(Date.now() - user.last_updated_at) : ''; - const timeInfo = since_last_update==='' ? vscode.l10n.t('Just now') : vscode.l10n.t('{since_last_update} ago', {since_last_update}); - tooltip.appendMarkdown(`${userInfo} ${cursorInfo} ${timeInfo}\n\n`); - }); - tooltip.isTrusted = true; - tooltip.supportHtml = true; - this.status.tooltip = tooltip; - break; - } - break; + const jumpArgs = JSON.stringify([user.id]); + const jumpCommandUri = vscode.Uri.parse(`command:${ROOT_NAME}.collaboration.jumpToUser?${encodeURIComponent(jumpArgs)}`); + const docPath = user.doc_id ? this.vfs._resolveById(user.doc_id)?.path.slice(1) : undefined; + const cursorInfo = user.row ? ` at ${docPath}#L${user.row+1}` : ''; + + const since_last_update = user.last_updated_at ? formatTime(Date.now() - user.last_updated_at) : ''; + const timeInfo = since_last_update==='' ? vscode.l10n.t('Just now') : vscode.l10n.t('{since_last_update} ago', {since_last_update}); + tooltip.appendMarkdown(`${userInfo} ${cursorInfo} ${timeInfo}\n\n`); + }); + tooltip.isTrusted = true; + tooltip.supportHtml = true; + this.status.tooltip = tooltip; + break; + } } this.status.show(); diff --git a/src/core/remoteFileSystemProvider.ts b/src/core/remoteFileSystemProvider.ts index 7c0a8eec..0746f732 100644 --- a/src/core/remoteFileSystemProvider.ts +++ b/src/core/remoteFileSystemProvider.ts @@ -117,6 +117,13 @@ export class VirtualFileSystem extends vscode.Disposable { private isDirty: boolean = true; private initializing?: Promise; private retryConnection: number = 0; + private retryTimer?: NodeJS.Timeout; + /** Whether a "Reconnecting..." notification is currently shown */ + private reconnectingNotification: boolean = false; + /** Timestamp of last disconnect for debounce */ + private lastDisconnectTime: number = 0; + /** Whether event handlers have been registered on the current socket */ + private handlersRegistered: boolean = false; private outputBuildId?: string; private compileGroup?: string; private clsiServerId?: string; @@ -177,63 +184,123 @@ export class VirtualFileSystem extends vscode.Disposable { } private get initializingPromise(): Promise { - // if retry connection failed 3 times, throw error - if (this.retryConnection >= 3) { + const MAX_RETRIES = 5; + const BASE_DELAY_MS = 1000; // 1 second base delay + + // if retry connection exhausted, show error + if (this.retryConnection >= MAX_RETRIES) { this.retryConnection = 0; - vscode.window.showErrorMessage( vscode.l10n.t('Connection lost: {serverName}', {serverName:this.serverName}), vscode.l10n.t('Reload')).then((choice) => { - if (choice==='Reload') { + this.initializing = undefined; + this.reconnectingNotification = false; + vscode.window.showErrorMessage( + vscode.l10n.t('Connection lost: {serverName}', {serverName:this.serverName}), + vscode.l10n.t('Reload'), + vscode.l10n.t('Retry'), + ).then((choice) => { + if (choice === vscode.l10n.t('Reload')) { vscode.commands.executeCommand("workbench.action.reloadWindow"); - }; + } else if (choice === vscode.l10n.t('Retry')) { + this.retryConnection = 0; + this.handlersRegistered = false; + this.socket.init(); // Recreate socket after all auto-reconnect attempts exhausted + this.initializing = this.initializingPromise; + this.init().catch(() => {}); + } }); - // reset retry connection - this.retryConnection = 0; - this.initializing = undefined; throw new Error( vscode.l10n.t('Connection lost') ); } - // if evert connection failed, reset socketio - if (this.retryConnection > 0) { - this.socket.init(); + + // exponential backoff delay: 1s, 2s, 4s, 8s, 16s + const delayMs = this.retryConnection > 0 ? Math.min(BASE_DELAY_MS * Math.pow(2, this.retryConnection - 1), 16000) : 0; + + // Show reconnecting notification on first retry + if (this.retryConnection === 1 && !this.reconnectingNotification) { + this.reconnectingNotification = true; + vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: vscode.l10n.t('Reconnecting to {serverName}...', {serverName:this.serverName}), + cancellable: false, + }, async () => { + // Keep the notification visible while reconnecting + await new Promise((resolve) => { + const check = () => { + if (this.root || this.retryConnection >= MAX_RETRIES) { + this.reconnectingNotification = false; + resolve(); + } else { + setTimeout(check, 500); + } + }; + check(); + }); + }); } - this.remoteWatch(); - this.root = undefined; - return this.socket.joinProject(this.projectId).then(async (project) => { - // fetch project settings - const identity = await GlobalStateManager.authenticate(this.context, this.serverName); - project.settings = (await this.api.getProjectSettings(identity, this.projectId)).settings!; - this.root = project; - const activeCondition = (vscode.workspace.workspaceFolders===undefined) || (vscode.workspace.workspaceFolders?.[0].uri.scheme!==ROOT_NAME) || (vscode.workspace.workspaceFolders?.[0].uri===this.origin); - // Register: [collaboration] ClientManager on Statusbar - if (activeCondition) { - if (this.clientManagerItem?.triggers) { - this.clientManagerItem.triggers.forEach((trigger) => trigger.dispose()); - delete this.clientManagerItem; - } - const clientManager = new ClientManager(this, this.context, this.publicId||'', this.socket); - this.clientManagerItem = { - manager: clientManager, - triggers: clientManager.triggers, - }; + // Wait for backoff delay before retrying + const attemptReconnect = async (): Promise => { + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)); } - // Register: [scm] SCMCollectionProvider in explorer - if (activeCondition) { - if (this.scmCollectionItem?.triggers) { - this.scmCollectionItem.triggers.forEach((trigger) => trigger.dispose()); - delete this.scmCollectionItem; - } - const scmCollection = new SCMCollectionProvider(this, this.context); - this.scmCollectionItem = { - collection: scmCollection, - triggers: scmCollection.triggers, - }; + + // Only recreate the socket when the connection scheme has changed + // (e.g., v1→v2 after connectionRejected). For transient disconnects, + // socket.io's built-in auto-reconnect handles re-establishing the TCP + // connection without creating a new one — avoiding TCP RST packets. + if (this.socket.needsReinit) { + this.socket.init(); + this.handlersRegistered = false; } - // trigger the first compile - vscode.commands.executeCommand(`${ROOT_NAME}.compileManager.compile`); - return project; - }).catch((err) => { - this.retryConnection += 1; - return this.initializingPromise; - }); + + // Register event handlers once on the current socket + if (!this.handlersRegistered) { + this.remoteWatch(); + this.handlersRegistered = true; + } + + this.root = undefined; + return this.socket.joinProject(this.projectId).then(async (project) => { + // Reset retry counter on success + this.retryConnection = 0; + this.reconnectingNotification = false; + // fetch project settings + const identity = await GlobalStateManager.authenticate(this.context, this.serverName); + project.settings = (await this.api.getProjectSettings(identity, this.projectId)).settings!; + this.root = project; + const activeCondition = (vscode.workspace.workspaceFolders===undefined) || (vscode.workspace.workspaceFolders?.[0].uri.scheme!==ROOT_NAME) || (vscode.workspace.workspaceFolders?.[0].uri===this.origin); + // Register: [collaboration] ClientManager on Statusbar + if (activeCondition) { + if (this.clientManagerItem?.triggers) { + this.clientManagerItem.triggers.forEach((trigger) => trigger.dispose()); + delete this.clientManagerItem; + } + const clientManager = new ClientManager(this, this.context, this.publicId||'', this.socket); + this.clientManagerItem = { + manager: clientManager, + triggers: clientManager.triggers, + }; + } + // Register: [scm] SCMCollectionProvider in explorer + if (activeCondition) { + if (this.scmCollectionItem?.triggers) { + this.scmCollectionItem.triggers.forEach((trigger) => trigger.dispose()); + delete this.scmCollectionItem; + } + const scmCollection = new SCMCollectionProvider(this, this.context); + this.scmCollectionItem = { + collection: scmCollection, + triggers: scmCollection.triggers, + }; + } + // trigger the first compile + vscode.commands.executeCommand(`${ROOT_NAME}.compileManager.compile`); + return project; + }).catch((err) => { + this.retryConnection += 1; + return this.initializingPromise; + }); + }; + + return attemptReconnect(); } get isInvisibleMode() { @@ -241,6 +308,13 @@ export class VirtualFileSystem extends vscode.Disposable { } toggleInvisibleMode() { + // Clear disconnect debounce to prevent false retry trigger during mode switch + this.lastDisconnectTime = 0; + this.handlersRegistered = false; // Will re-register on the new socket scheme + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } this.socket.toggleAlternativeConnectionScheme(this.origin.toString(), this.root); this.socket.disconnect(); // jump to `onDisconnected` handler } @@ -367,11 +441,31 @@ export class VirtualFileSystem extends vscode.Disposable { onDisconnected: () => { if (this.root===undefined) { return; } // bypass the first initialization console.log("Disconnected"); - this.retryConnection += 1; - this.initializing = this.initializingPromise; + // Debounce: ignore rapid disconnect/reconnect cycles (within 2 seconds) + const now = Date.now(); + if (now - this.lastDisconnectTime < 2000) { + console.log("Disconnected: debounced (too soon since last disconnect)"); + return; + } + this.lastDisconnectTime = now; + // Clear any pending retry timer + if (this.retryTimer) { + clearTimeout(this.retryTimer); + } + // Delay reconnection attempt slightly to allow transient issues to resolve + this.retryTimer = setTimeout(() => { + this.retryConnection += 1; + this.initializing = this.initializingPromise; + }, 1000); }, onConnectionAccepted: (publicId:string) => { this.retryConnection = 0; + this.reconnectingNotification = false; + this.lastDisconnectTime = 0; + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = undefined; + } this.publicId = publicId; }, onFileCreated: (parentFolderId:string, type:FileType, entity:FileEntity) => { From f2e3f63b70bc39f750e53fcceaae08c1bbc3ec61 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 8 May 2026 22:16:05 +0800 Subject: [PATCH 2/2] fix(scm): sync on save not fs changes (fixes #323, fixes #299) --- src/scm/localReplicaSCM.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/scm/localReplicaSCM.ts b/src/scm/localReplicaSCM.ts index efa885b9..7b772931 100644 --- a/src/scm/localReplicaSCM.ts +++ b/src/scm/localReplicaSCM.ts @@ -40,6 +40,7 @@ export class LocalReplicaSCMProvider extends BaseSCM { private baseCache: {[key:string]: Uint8Array} = {}; private vfsWatcher?: vscode.FileSystemWatcher; private localWatcher?: vscode.FileSystemWatcher; + private saveListener?: vscode.Disposable; private ignorePatterns: string[] = [ '**/.*', '**/.*/**', @@ -337,6 +338,20 @@ export class LocalReplicaSCMProvider extends BaseSCM { this.applySync('push', type, relPath, localUri, vfsUri); } + /** + * Push a saved document to the VFS. + * Only fires for explicit user saves in the editor, not for external + * file modifications (git, compilation tools, etc.). + * This is the general fix for issues #299 and #323. + */ + private onDocumentSaved(doc: vscode.TextDocument) { + const docUri = doc.uri; + // Only sync files within our baseUri (ensure path separator boundary) + const basePath = this.baseUri.path.endsWith('/') ? this.baseUri.path : this.baseUri.path + '/'; + if (!docUri.path.startsWith(basePath)) { return; } + this.syncToVFS(docUri, 'update'); + } + private async initWatch() { // write ".overleaf/settings.json" if not exist const settingUri = vscode.Uri.joinPath(this.baseUri, '.overleaf/settings.json'); @@ -361,15 +376,24 @@ export class LocalReplicaSCMProvider extends BaseSCM { ); await this.overwrite(); + // Listen for explicit user saves (not file system changes) to push local edits. + // File system watchers would also fire for git operations, compilation outputs, + // and other external modifications, causing unwanted sync (issues #299, #323). + this.saveListener = vscode.workspace.onDidSaveTextDocument( + doc => this.onDocumentSaved(doc) + ); + return [ // sync from vfs to local this.vfsWatcher.onDidChange(async uri => await this.syncFromVFS(uri, 'update')), this.vfsWatcher.onDidCreate(async uri => await this.syncFromVFS(uri, 'update')), this.vfsWatcher.onDidDelete(async uri => await this.syncFromVFS(uri, 'delete')), - // sync from local to vfs - this.localWatcher.onDidChange(async uri => await this.syncToVFS(uri, 'update')), + // sync from local to vfs: file updates via editor saves (onDidSaveTextDocument above), + // file creation and deletion still via watcher (these are explicit user actions) this.localWatcher.onDidCreate(async uri => await this.syncToVFS(uri, 'update')), this.localWatcher.onDidDelete(async uri => await this.syncToVFS(uri, 'delete')), + // include save listener for proper disposal + this.saveListener, ]; }