Skip to content

Commit d3f039d

Browse files
authored
Avoid unnecessary arrays. (#935)
Motivation: HTTP1ToGRPCServerCodec currently creates a temporary array for parsing all messages into before it forwards them on. This is both a minor perf drain (due to the extra allocations) and a correctness problem, as it makes this channel handler non-reentrant-safe. We should fix both issues. Modifications: - Replace the temporary array with a simple loop. - Add tests that validates correct behaviour on reentrancy. Result: Better re-entrancy behaviour! Verrrry slightly better perf.
1 parent 0c55132 commit d3f039d

File tree

3 files changed

+152
-8
lines changed

3 files changed

+152
-8
lines changed

Sources/GRPC/HTTP1ToGRPCServerCodec.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,12 @@ extension HTTP1ToGRPCServerCodec: ChannelInboundHandler {
270270
}
271271

272272
self.messageReader.append(buffer: &body)
273-
var requests: [ByteBuffer] = []
274273
do {
275-
while let buffer = try self.messageReader.nextMessage() {
276-
requests.append(buffer)
274+
// We may be re-entrantly called, and that re-entrant call may error. If the state changed for any reason,
275+
// stop looping.
276+
while self.inboundState == .expectingBody,
277+
let buffer = try self.messageReader.nextMessage() {
278+
context.fireChannelRead(self.wrapInboundOut(.message(buffer)))
277279
}
278280
} catch let grpcError as GRPCError.WithContext {
279281
context.fireErrorCaught(grpcError)
@@ -283,11 +285,9 @@ extension HTTP1ToGRPCServerCodec: ChannelInboundHandler {
283285
return .ignore
284286
}
285287

286-
requests.forEach {
287-
context.fireChannelRead(self.wrapInboundOut(.message($0)))
288-
}
289-
290-
return .expectingBody
288+
// We may have been called re-entrantly and transitioned out of the state we were in (e.g. because of an
289+
// error). In all cases, if we get here we want to persist the current state.
290+
return self.inboundState
291291
}
292292

293293
private func processEnd(context: ChannelHandlerContext,

Tests/GRPCTests/HTTP1ToGRPCServerCodecTests.swift

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,39 @@ import NIO
2222
import NIOHTTP1
2323
import XCTest
2424

25+
/// A trivial channel handler that invokes a callback once, the first time it sees
26+
/// channelRead.
27+
final class OnFirstReadHandler: ChannelInboundHandler {
28+
typealias InboundIn = Any
29+
typealias InboundOut = Any
30+
31+
private var callback: (() -> Void)?
32+
33+
init(callback: @escaping () -> Void) {
34+
self.callback = callback
35+
}
36+
37+
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
38+
context.fireChannelRead(data)
39+
40+
if let callback = self.callback {
41+
self.callback = nil
42+
callback()
43+
}
44+
}
45+
}
46+
47+
final class ErrorRecordingHandler: ChannelInboundHandler {
48+
typealias InboundIn = Any
49+
50+
var errors: [Error] = []
51+
52+
func errorCaught(context: ChannelHandlerContext, error: Error) {
53+
self.errors.append(error)
54+
context.fireErrorCaught(error)
55+
}
56+
}
57+
2558
class HTTP1ToGRPCServerCodecTests: GRPCTestCase {
2659
var channel: EmbeddedChannel!
2760

@@ -127,4 +160,113 @@ class HTTP1ToGRPCServerCodecTests: GRPCTestCase {
127160
}
128161
}
129162
}
163+
164+
func testReentrantMessageDelivery() throws {
165+
XCTAssertNoThrow(
166+
try self.channel
167+
.writeInbound(HTTPServerRequestPart.head(self.makeRequestHead()))
168+
)
169+
let requestPart = try self.channel.readInbound(as: _RawGRPCServerRequestPart.self)
170+
171+
switch requestPart {
172+
case .some(.head):
173+
()
174+
default:
175+
XCTFail("Unexpected request part: \(String(describing: requestPart))")
176+
}
177+
178+
// Write three messages into a single body.
179+
var buffer = self.channel.allocator.buffer(capacity: 0)
180+
let serializedMessages: [Data] = try ["foo", "bar", "baz"].map { text in
181+
Echo_EchoRequest.with { $0.text = text }
182+
}.map { request in
183+
try request.serializedData()
184+
}
185+
186+
for data in serializedMessages {
187+
buffer.writeInteger(UInt8(0))
188+
buffer.writeInteger(UInt32(data.count))
189+
buffer.writeBytes(data)
190+
}
191+
192+
// Create an OnFirstReadHandler that will _also_ send the data when it sees the first read.
193+
// This is try! because it cannot throw.
194+
let onFirstRead = OnFirstReadHandler {
195+
try! self.channel.writeInbound(HTTPServerRequestPart.body(buffer))
196+
}
197+
XCTAssertNoThrow(try self.channel.pipeline.addHandler(onFirstRead).wait())
198+
199+
// Now write.
200+
XCTAssertNoThrow(try self.channel.writeInbound(HTTPServerRequestPart.body(buffer)))
201+
202+
// This must not re-order messages.
203+
for message in [serializedMessages, serializedMessages].flatMap({ $0 }) {
204+
let requestPart = try self.channel.readInbound(as: _RawGRPCServerRequestPart.self)
205+
switch requestPart {
206+
case var .some(.message(buffer)):
207+
XCTAssertEqual(message, buffer.readData(length: buffer.readableBytes)!)
208+
default:
209+
XCTFail("Unexpected request part: \(String(describing: requestPart))")
210+
}
211+
}
212+
}
213+
214+
func testErrorsOnlyHappenOnce() throws {
215+
XCTAssertNoThrow(
216+
try self.channel
217+
.writeInbound(HTTPServerRequestPart.head(self.makeRequestHead()))
218+
)
219+
let requestPart = try self.channel.readInbound(as: _RawGRPCServerRequestPart.self)
220+
221+
switch requestPart {
222+
case .some(.head):
223+
()
224+
default:
225+
XCTFail("Unexpected request part: \(String(describing: requestPart))")
226+
}
227+
228+
// Write three messages into a single body.
229+
var buffer = self.channel.allocator.buffer(capacity: 0)
230+
let serializedMessages: [Data] = try ["foo", "bar", "baz"].map { text in
231+
Echo_EchoRequest.with { $0.text = text }
232+
}.map { request in
233+
try request.serializedData()
234+
}
235+
236+
for data in serializedMessages {
237+
buffer.writeInteger(UInt8(0))
238+
buffer.writeInteger(UInt32(data.count))
239+
buffer.writeBytes(data)
240+
}
241+
242+
// Create an OnFirstReadHandler that will _also_ send the data when it sees the first read.
243+
// This is try! because it cannot throw.
244+
let onFirstRead = OnFirstReadHandler {
245+
// Let's create a bad message: we'll turn on compression. We use two bytes here to deal with the fact that
246+
// in hitting the error we'll actually consume the first byte (whoops).
247+
var badBuffer = self.channel.allocator.buffer(capacity: 0)
248+
badBuffer.writeInteger(UInt8(1))
249+
badBuffer.writeInteger(UInt8(1))
250+
_ = try? self.channel.writeInbound(HTTPServerRequestPart.body(badBuffer))
251+
}
252+
let errorHandler = ErrorRecordingHandler()
253+
XCTAssertNoThrow(try self.channel.pipeline.addHandlers([onFirstRead, errorHandler]).wait())
254+
255+
// Now write.
256+
XCTAssertNoThrow(try self.channel.writeInbound(HTTPServerRequestPart.body(buffer)))
257+
258+
// We should have seen the original three messages
259+
for message in serializedMessages {
260+
let requestPart = try self.channel.readInbound(as: _RawGRPCServerRequestPart.self)
261+
switch requestPart {
262+
case var .some(.message(buffer)):
263+
XCTAssertEqual(message, buffer.readData(length: buffer.readableBytes)!)
264+
default:
265+
XCTFail("Unexpected request part: \(String(describing: requestPart))")
266+
}
267+
}
268+
269+
// We should have recorded only one error.
270+
XCTAssertEqual(errorHandler.errors.count, 1)
271+
}
130272
}

Tests/GRPCTests/XCTestManifests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,9 @@ extension HTTP1ToGRPCServerCodecTests {
646646
// `swift test --generate-linuxmain`
647647
// to regenerate.
648648
static let __allTests__HTTP1ToGRPCServerCodecTests = [
649+
("testErrorsOnlyHappenOnce", testErrorsOnlyHappenOnce),
649650
("testMultipleMessagesFromSingleBodyPart", testMultipleMessagesFromSingleBodyPart),
651+
("testReentrantMessageDelivery", testReentrantMessageDelivery),
650652
("testSingleMessageFromMultipleBodyParts", testSingleMessageFromMultipleBodyParts),
651653
]
652654
}

0 commit comments

Comments
 (0)