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
4 changes: 4 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
183 changes: 125 additions & 58 deletions src/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ResponseSchema> {
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) {
Expand Down
67 changes: 63 additions & 4 deletions src/api/socketio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,13 @@ export class SocketIOAPI {
private scheme: ConnectionScheme = 'v1';
private record?: Promise<ProjectEntity>;
private _handlers: Array<EventsHandler> = [];
/** 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,
Expand All @@ -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':
Expand Down Expand Up @@ -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() {
Expand All @@ -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') {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
});
Expand Down
Loading
Loading