Skip to content

Commit 227bb1e

Browse files
ctemtone
authored andcommitted
Fix socket-io client event handling (RooCodeInc#7618)
1 parent 82a31f7 commit 227bb1e

File tree

1 file changed

+101
-96
lines changed

1 file changed

+101
-96
lines changed

packages/cloud/src/bridge/SocketTransport.ts

Lines changed: 101 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export interface SocketTransportOptions {
88
onConnect?: () => void | Promise<void>
99
onDisconnect?: (reason: string) => void
1010
onReconnect?: (attemptNumber: number) => void | Promise<void>
11-
onError?: (error: Error) => void
1211
logger?: {
1312
log: (message: string, ...args: unknown[]) => void
1413
error: (message: string, ...args: unknown[]) => void
@@ -23,7 +22,6 @@ export interface SocketTransportOptions {
2322
export class SocketTransport {
2423
private socket: Socket | null = null
2524
private connectionState: ConnectionState = ConnectionState.DISCONNECTED
26-
private retryAttempt: number = 0
2725
private retryTimeout: NodeJS.Timeout | null = null
2826
private hasConnectedOnce: boolean = false
2927

@@ -45,6 +43,9 @@ export class SocketTransport {
4543
}
4644
}
4745

46+
// This is the initial connnect attempt. We need to implement our own
47+
// infinite retry mechanism since Socket.io's automatic reconnection only
48+
// kicks in after a successful initial connection.
4849
public async connect(): Promise<void> {
4950
if (this.connectionState === ConnectionState.CONNECTED) {
5051
console.log(`[SocketTransport] Already connected`)
@@ -56,49 +57,25 @@ export class SocketTransport {
5657
return
5758
}
5859

59-
// Start connection attempt without blocking.
60-
this.startConnectionAttempt()
61-
}
62-
63-
private async startConnectionAttempt() {
64-
this.retryAttempt = 0
65-
66-
try {
67-
await this.connectWithRetry()
68-
} catch (error) {
69-
console.error(
70-
`[SocketTransport] Unexpected error in connection loop: ${error instanceof Error ? error.message : String(error)}`,
71-
)
72-
}
73-
}
74-
75-
private async connectWithRetry(): Promise<void> {
60+
let attempt = 0
7661
let delay = this.retryConfig.initialDelay
7762

78-
while (this.retryAttempt < this.retryConfig.maxInitialAttempts) {
79-
try {
80-
this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
81-
82-
console.log(`[SocketTransport] Connection attempt ${this.retryAttempt + 1}`)
83-
84-
await this.connectSocket()
63+
while (attempt < this.retryConfig.maxInitialAttempts) {
64+
console.log(`[SocketTransport] Initial connect attempt ${attempt + 1}`)
65+
this.connectionState = attempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
8566

67+
try {
68+
await this._connect()
8669
console.log(`[SocketTransport] Connected to ${this.options.url}`)
87-
8870
this.connectionState = ConnectionState.CONNECTED
89-
this.retryAttempt = 0
90-
91-
this.clearRetryTimeouts()
9271

9372
if (this.options.onConnect) {
9473
await this.options.onConnect()
9574
}
9675

97-
return
98-
} catch (error) {
99-
this.retryAttempt++
100-
101-
console.error(`[SocketTransport] Connection attempt ${this.retryAttempt} failed:`, error)
76+
break
77+
} catch (_error) {
78+
attempt++
10279

10380
if (this.socket) {
10481
this.socket.disconnect()
@@ -107,40 +84,55 @@ export class SocketTransport {
10784

10885
console.log(`[SocketTransport] Waiting ${delay}ms before retry...`)
10986

110-
await this.delay(delay)
87+
const promise = new Promise((resolve) => {
88+
this.retryTimeout = setTimeout(resolve, delay)
89+
})
90+
91+
await promise
11192

11293
delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay)
11394
}
11495
}
96+
97+
if (this.retryTimeout) {
98+
clearTimeout(this.retryTimeout)
99+
this.retryTimeout = null
100+
}
101+
102+
if (this.connectionState === ConnectionState.CONNECTED) {
103+
console.log(`[SocketTransport] Connected to ${this.options.url}`)
104+
} else {
105+
this.connectionState = ConnectionState.FAILED
106+
console.error(`[SocketTransport] Failed to connect to ${this.options.url}, giving up`)
107+
}
115108
}
116109

117-
private async connectSocket(): Promise<void> {
110+
private async _connect(): Promise<void> {
118111
return new Promise((resolve, reject) => {
119112
this.socket = io(this.options.url, this.options.socketOptions)
120113

121-
const connectionTimeout = setTimeout(() => {
122-
console.error(`[SocketTransport] Connection timeout`)
114+
let connectionTimeout: NodeJS.Timeout | null = setTimeout(() => {
115+
console.error(`[SocketTransport] failed to connect after ${this.CONNECTION_TIMEOUT}ms`)
123116

124117
if (this.connectionState !== ConnectionState.CONNECTED) {
125118
this.socket?.disconnect()
126119
reject(new Error("Connection timeout"))
127120
}
128121
}, this.CONNECTION_TIMEOUT)
129122

123+
// https://socket.io/docs/v4/client-api/#event-connect
130124
this.socket.on("connect", async () => {
131-
clearTimeout(connectionTimeout)
125+
console.log(`[SocketTransport] on(connect)`)
132126

133-
const isReconnection = this.hasConnectedOnce
134-
135-
// If this is a reconnection (not the first connect), treat it as a
136-
// reconnect. This handles server restarts where 'reconnect' event might not fire.
137-
if (isReconnection) {
138-
console.log(`[SocketTransport] Treating connect as reconnection (server may have restarted)`)
127+
if (connectionTimeout) {
128+
clearTimeout(connectionTimeout)
129+
connectionTimeout = null
130+
}
139131

132+
if (this.hasConnectedOnce) {
140133
this.connectionState = ConnectionState.CONNECTED
141134

142135
if (this.options.onReconnect) {
143-
// Call onReconnect to re-register instance.
144136
await this.options.onReconnect(0)
145137
}
146138
}
@@ -149,9 +141,19 @@ export class SocketTransport {
149141
resolve()
150142
})
151143

152-
this.socket.on("disconnect", (reason: string) => {
153-
console.log(`[SocketTransport] Disconnected (reason: ${reason})`)
144+
// https://socket.io/docs/v4/client-api/#event-connect_error
145+
this.socket.on("connect_error", (error) => {
146+
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
147+
console.error(`[SocketTransport] on(connect_error): ${error.message}`)
148+
clearTimeout(connectionTimeout)
149+
connectionTimeout = null
150+
reject(error)
151+
}
152+
})
154153

154+
// https://socket.io/docs/v4/client-api/#event-disconnect
155+
this.socket.on("disconnect", (reason, details) => {
156+
console.log(`[SocketTransport] on(disconnect) (reason: ${reason}, details: ${JSON.stringify(details)})`)
155157
this.connectionState = ConnectionState.DISCONNECTED
156158

157159
if (this.options.onDisconnect) {
@@ -162,77 +164,82 @@ export class SocketTransport {
162164
const isManualDisconnect = reason === "io client disconnect"
163165

164166
if (!isManualDisconnect && this.hasConnectedOnce) {
165-
// After successful initial connection, rely entirely on Socket.IO's
166-
// reconnection.
167-
console.log(`[SocketTransport] Socket.IO will handle reconnection (reason: ${reason})`)
167+
// After successful initial connection, rely entirely on
168+
// Socket.IO's reconnection logic.
169+
console.log("[SocketTransport] will attempt to reconnect")
170+
} else {
171+
console.log("[SocketTransport] will *NOT* attempt to reconnect")
168172
}
169173
})
170174

171-
// Listen for reconnection attempts.
172-
this.socket.on("reconnect_attempt", (attemptNumber: number) => {
173-
console.log(`[SocketTransport] Socket.IO reconnect attempt:`, {
174-
attemptNumber,
175-
})
176-
})
175+
// https://socket.io/docs/v4/client-api/#event-error
176+
// Fired upon a connection error.
177+
this.socket.io.on("error", (error) => {
178+
// Connection error.
179+
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
180+
console.error(`[SocketTransport] on(error): ${error.message}`)
181+
clearTimeout(connectionTimeout)
182+
connectionTimeout = null
183+
reject(error)
184+
}
177185

178-
this.socket.on("reconnect", (attemptNumber: number) => {
179-
console.log(`[SocketTransport] Socket reconnected (attempt: ${attemptNumber})`)
186+
// Post-connection error.
187+
if (this.connectionState === ConnectionState.CONNECTED) {
188+
console.error(`[SocketTransport] on(error): ${error.message}`)
189+
}
190+
})
180191

192+
// https://socket.io/docs/v4/client-api/#event-reconnect
193+
// Fired upon a successful reconnection.
194+
this.socket.io.on("reconnect", (attempt) => {
195+
console.log(`[SocketTransport] on(reconnect) - ${attempt}`)
181196
this.connectionState = ConnectionState.CONNECTED
182197

183198
if (this.options.onReconnect) {
184-
this.options.onReconnect(attemptNumber)
199+
this.options.onReconnect(attempt)
185200
}
186201
})
187202

188-
this.socket.on("reconnect_error", (error: Error) => {
189-
console.error(`[SocketTransport] Socket.IO reconnect error:`, error)
203+
// https://socket.io/docs/v4/client-api/#event-reconnect_attempt
204+
// Fired upon an attempt to reconnect.
205+
this.socket.io.on("reconnect_attempt", (attempt) => {
206+
console.log(`[SocketTransport] on(reconnect_attempt) - ${attempt}`)
190207
})
191208

192-
this.socket.on("reconnect_failed", () => {
193-
console.error(`[SocketTransport] Socket.IO reconnection failed after all attempts`)
209+
// https://socket.io/docs/v4/client-api/#event-reconnect_error
210+
// Fired upon a reconnection attempt error.
211+
this.socket.io.on("reconnect_error", (error) => {
212+
console.error(`[SocketTransport] on(reconnect_error): ${error.message}`)
213+
})
194214

195-
this.connectionState = ConnectionState.RETRYING
215+
// https://socket.io/docs/v4/client-api/#event-reconnect_failed
216+
// Fired when couldn't reconnect within `reconnectionAttempts`.
217+
// Since we use infinite retries, this should never fire.
218+
this.socket.io.on("reconnect_failed", () => {
219+
console.error(`[SocketTransport] on(reconnect_failed) - giving up`)
220+
this.connectionState = ConnectionState.FAILED
196221
})
197222

198-
this.socket.on("error", (error) => {
199-
console.error(`[SocketTransport] Socket error:`, error)
223+
// This is a custom event fired by the server.
224+
this.socket.on("auth_error", (error) => {
225+
console.error(`[SocketTransport] on (auth_error):`, error)
200226

201-
if (this.connectionState !== ConnectionState.CONNECTED) {
227+
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
202228
clearTimeout(connectionTimeout)
203-
reject(error)
204-
}
205-
206-
if (this.options.onError) {
207-
this.options.onError(error)
229+
connectionTimeout = null
230+
reject(new Error(error.message || "Authentication failed"))
208231
}
209232
})
210-
211-
this.socket.on("auth_error", (error) => {
212-
console.error(`[SocketTransport] Authentication error:`, error)
213-
clearTimeout(connectionTimeout)
214-
reject(new Error(error.message || "Authentication failed"))
215-
})
216233
})
217234
}
218235

219-
private delay(ms: number): Promise<void> {
220-
return new Promise((resolve) => {
221-
this.retryTimeout = setTimeout(resolve, ms)
222-
})
223-
}
236+
public async disconnect(): Promise<void> {
237+
console.log(`[SocketTransport] Disconnecting...`)
224238

225-
private clearRetryTimeouts() {
226239
if (this.retryTimeout) {
227240
clearTimeout(this.retryTimeout)
228241
this.retryTimeout = null
229242
}
230-
}
231-
232-
public async disconnect(): Promise<void> {
233-
console.log(`[SocketTransport] Disconnecting...`)
234-
235-
this.clearRetryTimeouts()
236243

237244
if (this.socket) {
238245
this.socket.removeAllListeners()
@@ -241,7 +248,6 @@ export class SocketTransport {
241248
}
242249

243250
this.connectionState = ConnectionState.DISCONNECTED
244-
245251
console.log(`[SocketTransport] Disconnected`)
246252
}
247253

@@ -258,15 +264,14 @@ export class SocketTransport {
258264
}
259265

260266
public async reconnect(): Promise<void> {
267+
console.log(`[SocketTransport] Manually reconnecting...`)
268+
261269
if (this.connectionState === ConnectionState.CONNECTED) {
262270
console.log(`[SocketTransport] Already connected`)
263271
return
264272
}
265273

266-
console.log(`[SocketTransport] Manual reconnection requested`)
267-
268274
this.hasConnectedOnce = false
269-
270275
await this.disconnect()
271276
await this.connect()
272277
}

0 commit comments

Comments
 (0)