fix: clear pending pings to avoid promise leaks on connection close/suspend
Some checks failed
ci / macos (push) Has been cancelled
ci / ios (push) Has been cancelled
ci / check-linter (push) Has been cancelled

- Add cancel() method to RttCommand to fail promise on connection close
- Add dequeueAll() method to ConcurrentQueue for batch cleanup
- Call clearPendingPings() in close(), suspend(), and disconnect() methods
- Prevents 'leaking promise' crash when connection is closed while pings are pending
This commit is contained in:
wenzuhuai
2026-01-12 19:16:56 +08:00
parent d7bdb4f378
commit 153e600bbc
3 changed files with 31 additions and 0 deletions

View File

@@ -29,4 +29,13 @@ internal class ConcurrentQueue<T> {
guard !elements.isEmpty else { return nil } guard !elements.isEmpty else { return nil }
return elements.removeFirst() return elements.removeFirst()
} }
/// Dequeue all elements at once (used for cleanup)
func dequeueAll() -> [T] {
lock.lock()
defer { lock.unlock() }
let all = elements
elements.removeAll()
return all
}
} }

View File

@@ -712,6 +712,7 @@ class ConnectionHandler: ChannelInboundHandler {
guard let eventLoop = self.channel?.eventLoop else { guard let eventLoop = self.channel?.eventLoop else {
self.state.withLockedValue { $0 = .closed } self.state.withLockedValue { $0 = .closed }
self.pingTask?.cancel() self.pingTask?.cancel()
clearPendingPings() // Clear pending pings to avoid promise leaks
self.fire(.closed) self.fire(.closed)
return return
} }
@@ -720,6 +721,7 @@ class ConnectionHandler: ChannelInboundHandler {
eventLoop.execute { eventLoop.execute {
self.state.withLockedValue { $0 = .closed } self.state.withLockedValue { $0 = .closed }
self.pingTask?.cancel() self.pingTask?.cancel()
self.clearPendingPings() // Clear pending pings to avoid promise leaks
self.channel?.close(mode: .all, promise: promise) self.channel?.close(mode: .all, promise: promise)
} }
@@ -735,9 +737,21 @@ class ConnectionHandler: ChannelInboundHandler {
private func disconnect() async throws { private func disconnect() async throws {
self.pingTask?.cancel() self.pingTask?.cancel()
clearPendingPings() // Clear pending pings to avoid promise leaks
try await self.channel?.close().get() try await self.channel?.close().get()
} }
/// Clear all pending ping requests to avoid promise leaks
private func clearPendingPings() {
let pendingPings = pingQueue.dequeueAll()
for ping in pendingPings {
ping.cancel()
}
if !pendingPings.isEmpty {
logger.debug("Cleared \(pendingPings.count) pending ping(s)")
}
}
func suspend() async throws { func suspend() async throws {
self.reconnectTask?.cancel() self.reconnectTask?.cancel()
_ = try await self.reconnectTask?.value _ = try await self.reconnectTask?.value
@@ -746,6 +760,7 @@ class ConnectionHandler: ChannelInboundHandler {
guard let eventLoop = self.channel?.eventLoop else { guard let eventLoop = self.channel?.eventLoop else {
// Set state to suspended even if channel is nil // Set state to suspended even if channel is nil
self.state.withLockedValue { $0 = .suspended } self.state.withLockedValue { $0 = .suspended }
clearPendingPings() // Clear pending pings to avoid promise leaks
return return
} }
let promise = eventLoop.makePromise(of: Void.self) let promise = eventLoop.makePromise(of: Void.self)
@@ -759,8 +774,10 @@ class ConnectionHandler: ChannelInboundHandler {
if shouldClose { if shouldClose {
self.pingTask?.cancel() self.pingTask?.cancel()
self.clearPendingPings() // Clear pending pings to avoid promise leaks
self.channel?.close(mode: .all, promise: promise) self.channel?.close(mode: .all, promise: promise)
} else { } else {
self.clearPendingPings() // Clear pending pings even if not closing
promise.succeed() promise.succeed()
} }
} }

View File

@@ -36,4 +36,9 @@ internal class RttCommand {
func getRoundTripTime() async throws -> TimeInterval { func getRoundTripTime() async throws -> TimeInterval {
try await promise?.futureResult.get() ?? 0 try await promise?.futureResult.get() ?? 0
} }
/// Cancel the ping request to avoid promise leaks when connection is closed
func cancel() {
promise?.fail(NatsError.ClientError.connectionClosed)
}
} }