Coverage Report

Created: 2026-02-11 07:03

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/grpc-swift/Sources/GRPC/GRPCKeepaliveHandlers.swift
Line
Count
Source
1
/*
2
 * Copyright 2020, gRPC Authors All rights reserved.
3
 *
4
 * Licensed under the Apache License, Version 2.0 (the "License");
5
 * you may not use this file except in compliance with the License.
6
 * You may obtain a copy of the License at
7
 *
8
 *     http://www.apache.org/licenses/LICENSE-2.0
9
 *
10
 * Unless required by applicable law or agreed to in writing, software
11
 * distributed under the License is distributed on an "AS IS" BASIS,
12
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
 * See the License for the specific language governing permissions and
14
 * limitations under the License.
15
 */
16
import NIOCore
17
import NIOHTTP2
18
19
struct PingHandler {
20
  /// Opaque ping data used for keep-alive pings.
21
  private let pingData: HTTP2PingData
22
23
  /// Opaque ping data used for a ping sent after a GOAWAY frame.
24
  internal let pingDataGoAway: HTTP2PingData
25
26
  /// The amount of time to wait before sending a keepalive ping.
27
  private let interval: TimeAmount
28
29
  /// The amount of time to wait for an acknowledgment.
30
  /// If it does not receive an acknowledgment within this time, it will close the connection
31
  private let timeout: TimeAmount
32
33
  /// Send keepalive pings even if there are no calls in flight.
34
  private let permitWithoutCalls: Bool
35
36
  /// Maximum number of pings that can be sent when there is no data/header frame to be sent.
37
  private let maximumPingsWithoutData: UInt
38
39
  /// If there are no data/header frames being received:
40
  /// The minimum amount of time to wait between successive pings.
41
  private let minimumSentPingIntervalWithoutData: TimeAmount
42
43
  /// If there are no data/header frames being sent:
44
  /// The minimum amount of time expected between receiving successive pings.
45
  /// If the time between successive pings is less than this value, then the ping will be considered a bad ping from the peer.
46
  /// Such a ping counts as a "ping strike".
47
  /// Ping strikes are only applicable to server handler
48
  private let minimumReceivedPingIntervalWithoutData: TimeAmount?
49
50
  /// Maximum number of bad pings that the server will tolerate before sending an HTTP2 GOAWAY frame and closing the connection.
51
  /// Setting it to `0` allows the server to accept any number of bad pings.
52
  /// Ping strikes are only applicable to server handler
53
  private let maximumPingStrikes: UInt?
54
55
  /// When the handler started pinging
56
  private var startedAt: NIODeadline?
57
58
  /// When the last ping was received
59
  private var lastReceivedPingDate: NIODeadline?
60
61
  /// When the last ping was sent
62
  private var lastSentPingDate: NIODeadline?
63
64
  /// The number of pings sent on the transport without any data
65
11.5k
  private var sentPingsWithoutData = 0
66
67
  /// Number of strikes
68
11.5k
  private var pingStrikes: UInt = 0
69
70
  /// The scheduled task which will close the connection.
71
  private var scheduledClose: Scheduled<Void>?
72
73
  /// Number of active streams
74
11.5k
  private var activeStreams = 0 {
75
128k
    didSet {
76
128k
      if self.activeStreams > 0 {
77
89.3k
        self.sentPingsWithoutData = 0
78
89.3k
      }
79
128k
    }
80
  }
81
82
  private static let goAwayFrame = HTTP2Frame.FramePayload.goAway(
83
    lastStreamID: .rootStream,
84
    errorCode: .enhanceYourCalm,
85
    opaqueData: nil
86
  )
87
88
  // For testing only
89
  var _testingOnlyNow: NIODeadline?
90
91
  enum Action {
92
    case none
93
    case ack
94
    case schedulePing(delay: TimeAmount, timeout: TimeAmount)
95
    case cancelScheduledTimeout
96
    case reply(HTTP2Frame.FramePayload)
97
    case ratchetDownLastSeenStreamID
98
  }
99
100
  init(
101
    pingCode: UInt64,
102
    interval: TimeAmount,
103
    timeout: TimeAmount,
104
    permitWithoutCalls: Bool,
105
    maximumPingsWithoutData: UInt,
106
    minimumSentPingIntervalWithoutData: TimeAmount,
107
    minimumReceivedPingIntervalWithoutData: TimeAmount? = nil,
108
    maximumPingStrikes: UInt? = nil
109
6.98k
  ) {
110
6.98k
    self.pingData = HTTP2PingData(withInteger: pingCode)
111
6.98k
    self.pingDataGoAway = HTTP2PingData(withInteger: ~pingCode)
112
6.98k
    self.interval = interval
113
6.98k
    self.timeout = timeout
114
6.98k
    self.permitWithoutCalls = permitWithoutCalls
115
6.98k
    self.maximumPingsWithoutData = maximumPingsWithoutData
116
6.98k
    self.minimumSentPingIntervalWithoutData = minimumSentPingIntervalWithoutData
117
6.98k
    self.minimumReceivedPingIntervalWithoutData = minimumReceivedPingIntervalWithoutData
118
6.98k
    self.maximumPingStrikes = maximumPingStrikes
119
6.98k
  }
120
121
16.2k
  mutating func streamCreated() -> Action {
122
16.2k
    self.activeStreams += 1
123
16.2k
124
16.2k
    if self.startedAt == nil {
125
2.51k
      self.startedAt = self.now()
126
2.51k
      return .schedulePing(delay: self.interval, timeout: self.timeout)
127
13.7k
    } else {
128
13.7k
      return .none
129
13.7k
    }
130
16.2k
  }
131
132
11.5k
  mutating func streamClosed() -> Action {
133
11.5k
    self.activeStreams -= 1
134
11.5k
    return .none
135
11.5k
  }
136
137
5.96k
  mutating func read(pingData: HTTP2PingData, ack: Bool) -> Action {
138
5.96k
    if ack {
139
4.80k
      return self.handlePong(pingData)
140
4.80k
    } else {
141
1.15k
      return self.handlePing(pingData)
142
1.15k
    }
143
5.96k
  }
144
145
13.2k
  private func handlePong(_ pingData: HTTP2PingData) -> Action {
146
13.2k
    if pingData == self.pingData {
147
1.51k
      return .cancelScheduledTimeout
148
11.7k
    } else if pingData == self.pingDataGoAway {
149
10.3k
      // We received a pong for a ping we sent to trail a GOAWAY frame: this means we can now
150
10.3k
      // send another GOAWAY frame with a (possibly) lower stream ID.
151
10.3k
      return .ratchetDownLastSeenStreamID
152
10.3k
    } else {
153
1.44k
      return .none
154
1.44k
    }
155
13.2k
  }
156
157
2.94k
  private mutating func handlePing(_ pingData: HTTP2PingData) -> Action {
158
2.94k
    // Do we support ping strikes (only servers support ping strikes)?
159
2.94k
    if let maximumPingStrikes = self.maximumPingStrikes {
160
2.94k
      // Is this a ping strike?
161
2.94k
      if self.isPingStrike {
162
0
        self.pingStrikes += 1
163
0
164
0
        // A maximum ping strike of zero indicates that we tolerate any number of strikes.
165
0
        if maximumPingStrikes != 0, self.pingStrikes > maximumPingStrikes {
166
0
          return .reply(PingHandler.goAwayFrame)
167
0
        } else {
168
0
          return .none
169
0
        }
170
2.94k
      } else {
171
2.94k
        // This is a valid ping, reset our strike count and reply with a pong.
172
2.94k
        self.pingStrikes = 0
173
2.94k
        self.lastReceivedPingDate = self.now()
174
2.94k
        return .ack
175
2.94k
      }
176
2.94k
    } else {
177
0
      // We don't support ping strikes. We'll just reply with a pong.
178
0
      //
179
0
      // Note: we don't need to update `pingStrikes` or `lastReceivedPingDate` as we don't
180
0
      // support ping strikes.
181
0
      return .ack
182
0
    }
183
2.94k
  }
184
185
0
  mutating func pingFired() -> Action {
186
0
    if self.shouldBlockPing {
187
0
      return .none
188
0
    } else {
189
0
      return .reply(self.generatePingFrame(data: self.pingData))
190
0
    }
191
0
  }
192
193
  private mutating func generatePingFrame(
194
    data: HTTP2PingData
195
0
  ) -> HTTP2Frame.FramePayload {
196
0
    if self.activeStreams == 0 {
197
0
      self.sentPingsWithoutData += 1
198
0
    }
199
0
200
0
    self.lastSentPingDate = self.now()
201
0
    return HTTP2Frame.FramePayload.ping(data, ack: false)
202
0
  }
203
204
  /// Returns true if, on receipt of a ping, the ping should be regarded as a ping strike.
205
  ///
206
  /// A ping is considered a 'strike' if:
207
  /// - There are no active streams.
208
  /// - We allow pings to be sent when there are no active streams (i.e. `self.permitWithoutCalls`).
209
  /// - The time since the last ping we received is less than the minimum allowed interval.
210
  ///
211
  /// - Precondition: Ping strikes are supported (i.e. `self.maximumPingStrikes != nil`)
212
2.94k
  private var isPingStrike: Bool {
213
2.94k
    assert(
214
2.94k
      self.maximumPingStrikes != nil,
215
2.94k
      "Ping strikes are not supported but we're checking for one"
216
2.94k
    )
217
2.94k
    guard self.activeStreams == 0, self.permitWithoutCalls,
218
2.94k
      let lastReceivedPingDate = self.lastReceivedPingDate,
219
2.94k
      let minimumReceivedPingIntervalWithoutData = self.minimumReceivedPingIntervalWithoutData
220
2.94k
    else {
221
2.94k
      return false
222
2.94k
    }
223
0
224
0
    return self.now() - lastReceivedPingDate < minimumReceivedPingIntervalWithoutData
225
2.94k
  }
226
227
0
  private var shouldBlockPing: Bool {
228
0
    // There is no active call on the transport and pings should not be sent
229
0
    guard self.activeStreams > 0 || self.permitWithoutCalls else {
230
0
      return true
231
0
    }
232
0
233
0
    // There is no active call on the transport but pings should be sent
234
0
    if self.activeStreams == 0, self.permitWithoutCalls {
235
0
      // The number of pings already sent on the transport without any data has already exceeded the limit
236
0
      if self.sentPingsWithoutData > self.maximumPingsWithoutData {
237
0
        return true
238
0
      }
239
0
240
0
      // The time elapsed since the previous ping is less than the minimum required
241
0
      if let lastSentPingDate = self.lastSentPingDate,
242
0
        self.now() - lastSentPingDate < self.minimumSentPingIntervalWithoutData
243
0
      {
244
0
        return true
245
0
      }
246
0
247
0
      return false
248
0
    }
249
0
250
0
    return false
251
0
  }
252
253
7.04k
  private func now() -> NIODeadline {
254
7.04k
    return self._testingOnlyNow ?? .now()
255
7.04k
  }
256
}