vapor / websocket-kit

WebSocket client library built on SwiftNIO
https://docs.vapor.codes/4.0/advanced/websockets/
MIT License
277 stars 79 forks source link

Support sending FileRegion #63

Closed bridger closed 4 years ago

bridger commented 4 years ago

There are some circumstances in my app where I'd like to send data directly from a file to a WebSocket. Is it possible to support that, without loading the file contents into memory?

I see that Channel.writeAndFlush can take a FileRegion, which conforms to NIOAny. Using that type can allow SwiftNIO to send the file without loading it into memory. However, the docs for FileRegion say:

depending your 'ChannelPipeline' setup it may not be possible to use a FileRegion as a ChannelHandler may need access to the bytes (in a ByteBuffer) to transform these.

Does anyone have a good guess (or know where to look) to see if the ChannelHandlers in Websocket-Kit and SwiftNIO need access directly to a ByteBuffer?

tanner0101 commented 4 years ago

That's a good question, @weissi would know more about the FileRegion stuff.

Although I think the main concern here is that you're not loading the entire file into memory first or allowing the file's contents to balloon in memory. If you use Vapor's FileIO helpers (built on top of NIO's non blocking file io type) you can read in a file chunk by chunk with back pressure support. This means that you should theoretically be able to wait until the WebSocket has successfully written the chunk before reading a new one into memory. That should be very efficient without needing to get into the weeds with FileRegion stuff.

weissi commented 4 years ago

@bridger NIO (on sockets) does support sending FileRegion it'll use the sendfile system call to send them. That fulfils your requirement of "not being loaded into memory". However, these days, sendfile is less useful because it doesn't support TLS (in theory possible with Kernel TLS but yeah, not really a thing), it'll literally send a file through a socket.

So what @tanner0101 proposes is the right way to go. NIO has all the tools that are required to only load a fixed sized chunk into memory at once and Vapor 4 seems to make use of them :)

weissi commented 4 years ago

@tanner0101 / @bridger One thing that we do want to eventually do however is to have the SSLHandler support FileRegions by itself making use of NonBlockingFileIO and sending the file (it will however load that into memory of course). We're tracking the work here: https://github.com/apple/swift-nio-ssl/issues/175

It's important to add that even if we implemented that, it won't be any better than if you implement it exactly the way @tanner0101 proposes :)

bridger commented 4 years ago

Sweet. I’m onboard with the idea that incrementally loaded chunks is a solution that will be more useful than sendfile. It would also allow the messages to go through a compression pipeline.

One tricky thing is making sure that complete messages are still sent in order. For example, if I have two calls - send a file and then send a Data message, I’d like to see that all chunks from the first file are sent before the Data message is sent.

Sent with GitHawk

weissi commented 4 years ago

@bridger Doing this outside a handler is quite straightforward: Use readChunked and in the chunkHandler closure just pass channel.writeAndFlush :).

NonBlockingFileIO will only send you the next chunk if the previous one completed the future (making sure we don't load too much into memory). NIO will also take care of the ordering for you. If you enqueue stuff in NIO in the right order, it'll come out in the right order.

Implementing a handler that takes OutboundIn = FileRegion and produces OutboundOut = ByteBuffer by loading chunks of a file is indeed not super easy. You'll need to buffer everything incoming until you fully processed the previous item. I think the Perfect folks were working on something like this though and eventually this should be part of NIO's SSL handlers :).

tanner0101 commented 4 years ago

@bridger here's some rough pseudo-code of what I mentioned in the first comment:

let fileio: NonBlockingFileIO

fileio.readChunked(...) { chunk in
    let promise = el.makePromise(...)
    ws.send(chunk, promise: promise)
    return promise.futureResult
}