Clarifications on NIO bytebuffer capacity

Hi

I'm currently writing a library that wraps another API and I'm using the NIOHTTP1TestServer to assert my library's requests/responses. But I'm seeing some failing tests that do not occur on my Mac, but do occur on Linux, these are around the capacity allocated for byte buffers. In essence the code I'm using is the following.

Which is taken from the NIOHTTP1TestServer docs swift-nio/NIOHTTP1TestServer.swift at 2d913d65af1a4f3f72cee2c71f565d7a07f50105 · apple/swift-nio · GitHub

XCTAssertNoThrow(requestComplete = try! Self.utilities.client.caseFields.add(caseField: Self.utilities.newCaseFieldRequestObject))

        var requestBuffer = Self.utilities.allocator.buffer(capacity: 0)
        try! requestBuffer.writeJSONEncodable(Self.utilities.newCaseFieldRequestObject, encoder: Self.utilities.encoder)
        let contentLength = requestBuffer.readableBytes

        XCTAssertNoThrow(XCTAssertEqual(.head(.init(...test headers, uri, method etc...)))) // passes

        XCTAssertNoThrow(XCTAssertEqual(.body(requestBuffer), try Self.utilities.testServer.readInbound())) // fails on Linux

This will fail at the .body() assertion. With the following error:

"body(ByteBuffer { readerIndex: 0, writerIndex: 226, readableBytes: 226, capacity: 256, slice: _ByteBufferSlice { 0..<256 }, storage: 0x00005613cfc5a8d0 (256 bytes) })") is not equal to (
"body(ByteBuffer { readerIndex: 0, writerIndex: 226, readableBytes: 226, capacity: 226, slice: _ByteBufferSlice { 201..<427 }, storage: 0x00007f1d000439a0 (1024 bytes) })") - 

This reads to me that the expected object is received as both byte length matches. But it seems that on the Mac the capacity is larger than on Linux?

Originally I manually set the capacity to a number larger than the the object, which is where I first noticed the error on Linux, I've since moved to setting it to 0, because it seems from the source that the buffer will expand as needed.

I also see in the source

    /// The current capacity of the storage of this `ByteBuffer`, this is not constant and does _not_ signify the number
    /// of bytes that have been written to this `ByteBuffer`.
    public var capacity: Int {
        return self._slice.count
    }

These always pass on my machine running 5.3, but fail on swift:5.3-focal. So is this an expected behaviour between the two OS?

I should add, that I have another 35 tests and they don't exhibit this behaviour and pass fine in Docker and my machine. It seems to be just these two for some reason?

I am not aware of any different allocation strategies on different platforms. ByteBuffer uses malloc underhood which allocates the exact amount of memory that it's asked for.

Can you provide a bit more details about what server setup you have?

My first guess here would be that something in the HTTP headers is different and therefore larger which means that some data will get onto a second slice in the ByteBuffer, either by having different headers (e.g. a longer Host header due to a different hostname, so something like a TLS handshake that is either not present or generates more data because of different TLS versions or different ciphers/keys)?

One easy way to debug what data actually went in/out, you can put the WritePCAPHandler (from swift-nio-extras) at the top of your pipeline to get a PCAP file with the exact traffic.

You can use that to inspect the traffic that was processed by Swift-NIO. I think it would be useful to see if there is a difference.

I am not sure how wise it is to compare the buffer by itself; what buffer you are getting depends on a few hard to control circumstances.

If you look at the AggregateBodyHandler handler in NIOHTTP1TestServer, you can see that if there is only one channelRead invocation you'll get the original buffer. If there is more than one invocation the contents for the second read will be appended to the first buffer.

private final class AggregateBodyHandler: ChannelInboundHandler {
    typealias InboundIn = HTTPServerRequestPart
    typealias InboundOut = HTTPServerRequestPart

    var receivedSoFar: ByteBuffer? = nil

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        switch self.unwrapInboundIn(data) {
        case .head:
            context.fireChannelRead(data)
        case .body(var buffer):
            self.receivedSoFar.setOrWriteBuffer(&buffer)
        case .end:
            if let receivedSoFar = self.receivedSoFar {
                context.fireChannelRead(self.wrapInboundOut(.body(receivedSoFar)))
            }
            context.fireChannelRead(data)
        }
    }
}

The capacity of bytebuffers is not semantic and does not play into the equatability of two byte buffers. It is an implementation detail.

You should check the bytes themselves: they are necessarily different.

Thanks both @lukasa and @tbartelmess :slight_smile:

I took this approach in the end:

        var inboundBody: HTTPServerRequestPart?
        XCTAssertNoThrow(inboundBody = try Self.utilities.testServer.readInbound())
        guard case .body(let body) = try! XCTUnwrap(inboundBody) else { XCTFail("Expected to get a body"); return }

        XCTAssertEqual(requestBuffer.readableBytes, body.readableBytes) // used rather than the byte array as the encoding was resulting in the two objects properties being represented in different orders like {"a": 1, "b": 2} vs {"b": 2, "a": 1} 
        // some additional property checks here...

You can still compare the contents of the bytes in the ByteBuffer just as @lukasa mentioned the buffer itself should not be compared.

Right now can either read the bytes out of the buffer. If you just want to inspect the buffer you can use some like:

guard let requestBufferBytes = requestBuffer.readBytes(length: requestBuffer.readableBytes),
      let bodyBufferBytes = body.readBytes(length: body.readableBytes) else {
    XCTFail("Failed to read bytes")
}
XCTAssertEqual(requestBufferBytes, bodyBufferBytes)

Note that this will consume the bytes available in the buffer, so you can only read them once. If you want to not consume but just "peek" into the buffer, you can use getBytes

1 Like

Yeah, please do compare the bytes in the buffer. Right now you’re confirming only that you have the same number of bytes, which is probably not what you care about!

1 Like

I did originally use this, but for these 2 tests, I have trouble with the order of elements, for example:

SERVER
{"description":"very descriptive","label":"Case Label","include_all":true,"name":"Brand New Case","config":{"options":{"is_required":true},"context":{"project_ids":[1],"is_global":true}},"template_ids":[],"type":"Multiselect"}
EXPECTED
{"label":"Case Label","name":"Brand New Case","config":{"options":{"is_required":true},"context":{"project_ids":[1],"is_global":true}},"description":"very descriptive","type":"Multiselect","template_ids":[],"include_all":true}

Though I see now perhaps I should have used .sortedKeys