Skip to content

Commit cb6f862

Browse files
authored
fix: add timeout and close handling to connection.ready() (#253)
* fix: add timeout and close handling to connection.ready() If neither 'connect' nor 'error' fires while waiting in ready(), the callback never completes, causing pool.get() and kPerformWithRetry to hang indefinitely. This adds a timeout using the existing connectTimeout option and also listens for 'close' events. Signed-off-by: Tristan Burch <tristan@day.ai> * fix: resolve ready() timeout race conditions in connection - Use host:port in timeout message when available for consistency with socket timeout - Destroy socket when ready() timeout fires to prevent racing socket timeout from emitting an unhandled error event after listeners are cleaned up - Skip timeout callback when connection is already in ERROR/CLOSED state to prevent double-invocation when connect() throws synchronously (e.g. invalid port) Signed-off-by: Tristan Burch <tristan@tburch.com> Signed-off-by: Tristan Burch <tristan@day.ai> * test: add tests for ready() timeout and close handling Signed-off-by: Tristan Burch <tristan@day.ai> * 'TimeoutError' is not used Signed-off-by: Tristan Burch <tristan@day.ai> * fix: check membershipActive in updateAssignments metadata callback When leaveGroup() is called during the metadata-insync phase of a rebalance, the metadata callback in #updateAssignments() continued unconditionally and overwrote assignments. Add a #membershipActive guard consistent with #performJoinGroup() and #performSyncGroup(). Signed-off-by: Tristan Burch <tristan@day.ai> --------- Signed-off-by: Tristan Burch <tristan@day.ai> Signed-off-by: Tristan Burch <tristan@tburch.com>
1 parent f126625 commit cb6f862

3 files changed

Lines changed: 59 additions & 3 deletions

File tree

src/clients/consumer/consumer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,11 @@ export class Consumer<Key = Buffer, Value = Buffer, HeaderKey = Buffer, HeaderVa
13611361

13621362
#updateAssignments (newAssignments: TopicPartition[], callback: CallbackWithPromise<void>): void {
13631363
this[kMetadata]({ topics: this.topics.current }, (error, metadata) => {
1364+
if (!this.#membershipActive) {
1365+
callback(null)
1366+
return
1367+
}
1368+
13641369
if (error) {
13651370
callback(error)
13661371
return

src/network/connection.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,20 +308,47 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
308308
callback = createPromisifiedCallback()
309309
}
310310

311-
const onConnect = () => {
311+
const cleanup = () => {
312+
clearTimeout(timeout)
313+
this.removeListener('connect', onConnect)
312314
this.removeListener('error', onError)
315+
this.removeListener('close', onClose)
316+
}
313317

318+
const onConnect = () => {
319+
cleanup()
314320
callback!(null)
315321
}
316322

317323
const onError = (error: Error) => {
318-
this.removeListener('connect', onConnect)
319-
324+
cleanup()
320325
callback!(error)
321326
}
322327

328+
const onClose = () => {
329+
cleanup()
330+
callback!(new NetworkError('Connection closed while waiting for ready.'))
331+
}
332+
333+
const timeout = setTimeout(() => {
334+
cleanup()
335+
336+
// If the connection already failed (e.g. invalid port), don't double-invoke the callback
337+
if (this.#status === ConnectionStatuses.ERROR || this.#status === ConnectionStatuses.CLOSED) {
338+
return
339+
}
340+
341+
this.#socket?.destroy()
342+
callback!(new TimeoutError(
343+
this.#host
344+
? `Connection to ${this.#host}:${this.#port} timed out.`
345+
: `Connection ready timed out after ${this.#options.connectTimeout}ms.`
346+
))
347+
}, this.#options.connectTimeout)
348+
323349
this.once('connect', onConnect)
324350
this.once('error', onError)
351+
this.once('close', onClose)
325352
this.emit('ready')
326353

327354
return callback[kCallbackPromise]

test/network/connection.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,30 @@ test('Connection.ready should reject when connection errors', async t => {
297297
await readyPromise
298298
})
299299

300+
test('Connection.ready should reject with TimeoutError when connection does not become ready in time', async t => {
301+
const connection = new Connection('test-client', { connectTimeout: 100 })
302+
t.after(() => connection.close())
303+
304+
await rejects(() => connection.ready() as Promise<unknown>, {
305+
message: 'Connection ready timed out after 100ms.'
306+
})
307+
})
308+
309+
test('Connection.ready should reject with NetworkError when connection closes while waiting', async t => {
310+
const connection = new Connection('test-client', { connectTimeout: 5000 })
311+
t.after(() => connection.close())
312+
313+
const readyPromise = connection.ready()
314+
315+
// Simulate a close event while waiting for ready
316+
connection.emit('close')
317+
318+
await rejects(() => readyPromise as Promise<unknown>, {
319+
code: 'PLT_KFK_NETWORK',
320+
message: 'Connection closed while waiting for ready.'
321+
})
322+
})
323+
300324
test('Connection.close should close the connection', async t => {
301325
const { port } = await createServer(t)
302326
const connection = new Connection('test-client')

0 commit comments

Comments
 (0)