Skip to content
Closed
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
8 changes: 5 additions & 3 deletions src/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,12 @@ export class BaseAPI {

// Reference: "github:overleaf/overleaf/services/web/frontend/js/ide/connection/ConnectionManager.js#L137"
_initSocketV0(identity:Identity, query?:string) {
const url = new URL(this.url).origin + (query ?? '');
return (require('socket.io-client').connect as any)(url, {
const baseUrl = new URL(this.url).origin;
const queryString = query?.startsWith('?') ? query.slice(1) : query;
return (require('socket.io-client').connect as any)(baseUrl, {
reconnect: false,
'force new connection': true,
...(queryString ? {query: queryString} : {}),
extraHeaders: {
'Origin': new URL(this.url).origin,
'Cookie': identity.cookies,
Expand Down Expand Up @@ -601,7 +603,7 @@ export class BaseAPI {
};

this.setIdentity(identity);
return this.request('POST', `project/${projectId}/compile?auto_compile=true`, body, (res) => {
return this.request('POST', `project/${projectId}/compile`, body, (res) => {
const compile = JSON.parse(res!) as CompileResponseSchema;
return {compile};
}, {'X-Csrf-Token': identity.csrfToken});
Expand Down
74 changes: 54 additions & 20 deletions src/api/socketio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface EventsHandler {
type ConnectionScheme = 'Alt' | 'v1' | 'v2';

export class SocketIOAPI {
private scheme: ConnectionScheme = 'v1';
private scheme: ConnectionScheme = 'v2';
private record?: Promise<ProjectEntity>;
private _handlers: Array<EventsHandler> = [];

Expand Down Expand Up @@ -289,31 +289,65 @@ export class SocketIOAPI {
* @returns {Promise}
*/
async joinProject(project_id:string): Promise<ProjectEntity> {
if (this.scheme==='v2') {
try {
return await this.joinProjectByV2();
} catch (err) {
// Old Overleaf stacks may not support auto join via handshake.
// Fallback to legacy joinProject event to keep compatibility.
this.scheme = 'v1';
this.init();
return this.joinProjectByV1(project_id);
}
}

try {
return await this.joinProjectByV1(project_id);
} catch (err) {
if (!this.shouldFallbackToV2(err)) {
throw err;
}
// New Overleaf stacks require projectId in handshake query.
this.scheme = 'v2';
this.init();
return this.joinProjectByV2();
Comment on lines +296 to +313

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

When falling back between v1/v2 you call this.init() to create a new Socket.IO connection, but the existing this.socket is never disconnected first. This can leave multiple live connections and duplicated event handlers (especially if joinProject retries), leading to duplicated events and resource leaks. Consider disconnecting/cleaning up the current socket (and its listeners) before re-initializing, e.g., this.socket?.disconnect() plus removing listeners, or refactor init() to safely re-create the socket.

Copilot uses AI. Check for mistakes.
}
}

private async joinProjectByV1(project_id:string): Promise<ProjectEntity> {
const timeoutPromise: Promise<ProjectEntity> = new Promise((_, reject) => {
setTimeout(() => {
reject('timeout');
}, 5000);
});

switch(this.scheme) {
case 'Alt':
case 'v1':
const joinPromise = this.emit('joinProject', {project_id})
.then((returns:[ProjectEntity, string, number]) => {
const [project, permissionsLevel, protocolVersion] = returns;
this.record = Promise.resolve(project);
return project;
});
const rejectPromise = new Promise((_, reject) => {
this.socket.on('connectionRejected', (err:any) => {
this.scheme = 'v2';
reject(err.message);
});
});
return Promise.race([joinPromise, rejectPromise, timeoutPromise]);
case 'v2':
return Promise.race([this.record!, timeoutPromise]);
}
const joinPromise = this.emit('joinProject', {project_id})
.then((returns:[ProjectEntity, string, number]) => {
const [project, permissionsLevel, protocolVersion] = returns;
this.record = Promise.resolve(project);
return project;
});
const rejectPromise: Promise<ProjectEntity> = new Promise((_, reject) => {
this.socket.on('connectionRejected', (err:any) => {
reject(err?.message ?? err);
});
});
Comment on lines +330 to +334

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

joinProjectByV1 registers a connectionRejected listener via this.socket.on(...) but never removes it. If joinProject is called multiple times (or retries occur), these listeners will accumulate and may trigger multiple rejections for a single event. Using once here and/or explicitly removing the listener after the race resolves would prevent listener leaks.

Copilot uses AI. Check for mistakes.

return Promise.race([joinPromise, rejectPromise, timeoutPromise]);
}

private async joinProjectByV2(): Promise<ProjectEntity> {
const timeoutPromise: Promise<ProjectEntity> = new Promise((_, reject) => {
setTimeout(() => {
reject('timeout');
}, 5000);
});
return Promise.race([this.record!, timeoutPromise]);
}

private shouldFallbackToV2(err: unknown): boolean {
const message = String(err ?? '').toLowerCase();
return message.includes('missing/bad ?projectid') || message.includes('bad projectid') || message.includes('missing projectid');
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/compile/compileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,12 @@ export class CompileManager {
.then(status =>
status ?
vscode.commands.executeCommand(`${ROOT_NAME}.compileManager.compileErrorCheck`, uri)
: Promise.reject()
: undefined
)
.then(async (hasError) => {
if (hasError===undefined) {
return;
}
if (hasError) {
await this.update('failed');
} else {
Expand All @@ -239,6 +242,10 @@ export class CompileManager {
pdfViewRecord[identifier] && Object.values(pdfViewRecord[identifier]).forEach(
(record) => record.doc.refresh()
);
})
.catch(async (error) => {
console.error(`${ELEGANT_NAME}: compile chain failed`, error);
await this.update('failed');
});

}
Expand Down
17 changes: 10 additions & 7 deletions src/core/remoteFileSystemProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,15 +885,18 @@ export class VirtualFileSystem extends vscode.Disposable {
}
// compile project
const res = await this.api.compile(identity, this.projectId, rootDocId??this.root?.rootDoc_id??null, draft, stopOnFirstError);
if (res.type==='success' && res.compile?.status==='success') {
this.updateOutputs(res.compile.outputFiles);
return true;
} else {
if (res.message!==undefined) {
console.error('Compile failure.', res.message);
if (res.type==='success' && res.compile) {
const outputFiles = res.compile.outputFiles ?? [];
if (outputFiles.length > 0) {
this.updateOutputs(outputFiles);
return true;
}
return false;
}

if (res.message!==undefined) {
console.error('Compile failure.', res.message);
}
return false;
}
return Promise.resolve(undefined);
}
Expand Down
Loading