- From: Peter Thatcher <pthatcher@google.com>
- Date: Thu, 13 Jul 2017 22:33:48 +0000
- To: "public-ortc@w3.org" <public-ortc@w3.org>
- Message-ID: <CAJrXDUEqTonaLiVk_G+hZup3h8uXnGMJtkCFaLWpySMGbenn2Q@mail.gmail.com>
Oops... those EventHandlers shouldn't be readonly. That wouldn't be very useful :). On Thu, Jul 13, 2017 at 4:42 AM Peter Thatcher <pthatcher@google.com> wrote: > There has been a lot of interest in adding QUIC-based data channels to > ORTC, and it seems like a natural fit to layer some new QuicTransport on > top of an IceTransport to produce data channels the same way we do form an > SctpTransport layered on top of a DtlsTransport on top of an IceTransport. > > But as I have spent time thinking about it and designing it, I realized > that there is a more fundamental primitive that we can expose, slightly > lower-level than data channels. And if we give that primitive an API, it > would not only be possible to implement data channels on top, it would be > possible to build many other things on top in a way that would be more > simple, powerful, and flexible at the same time. > > That powerful fundamental primitive is the QUIC stream. > > Here's as simplified version of the API I propose (I slightly more full > one a little later), centered around QUIC streams: > > [Constructor(IceTransport)] > partial interface QuicTransport { > QuicStream createStream(); > attribute EventHandler onstream; // QuicStream from remote side > }; > > partial interface QuicStream { > // If there is space in the local buffer, write to the buffer. > // Otherwise, fail. > void write(Uint8Array data); > > // Will maybe write one last value before finishing > // and then a "fin" bit. > void finish(optional Uint8Array data); > > // The remote side has acked everything, including the "fin" bit > // Which means it is safe to close the stream. > readonly attribute EventHandler onacked; > > // Reads all that is in the buffer. > Uint8Array read(); > > // Fills the array exactly from the read buffer. > // If that amount is not available, it fails. > void readInto(Uint8Array data); > > // Closing on either side trigger on close on both sides. > // Once closed, all future reads and writes will fail. > // Nothing will be sent or received any longer. > // Everything buffered will be thrown away. > // Closing an already closed stream does nothing. > void close(); > attribute EventHandler onclosed; > } > > > Basically, you can write, read, and close. You can also "finish", know > when the remote side has "finished" and know when the remote side has acked > everything. > > From here, as I'll show with some examples, you can send and receive small > message out of order or with time-bounded reliability. You can also send > and receive large messages with back pressure. You can build data > channels. Or you can even build WHATWG streams. > > But there's just one last piece to the puzzle to explain: buffering. In > order for back pressure to work properly and easily we need a way of > causing the app to wait until there is room in a limited-size buffer to > send and receive. In the examples, you'll see how this is used, and then > I'll show the methods to accomplish this. > > Example of sending small unreliable/unordered messages with a short > timeout: > let ice = ...; > let quic = new QuicTransport(ice); > let maxMessageSize = 4096; > let timeout = 5000; > > // Write outgoing message > let data = ...; > let qstream = quic.createStream(); > await qstream.waitForWritable(data.byteLength); > qstream.finish(data); > setTimeout(() => qstream.close(), timeout); > > // Read incoming messages. > quic.onstream = qstream => { > await qstream.waitForReadableOrFinished(maxMessageSize); > let data = qstream.read(); > // Do something with data. > }; > > > Example of sending a large message in chunks, with back pressure > let ice = ...; > let quic = new QuicTransport(ice); > > // Write out in chunks > let qstream = quic.createStream(); > let chunks = ...; > for chunk in chunks { > await qstream.waitForWritable(chunk.byteLength); > qstream.write(chunk); > } > > // Read in the chunks > quic.onstream = qstream => { > await qstream.waitForReadableOrFinished(); > while (!qstream.finished) { > let data = qstream.read(); > // .... Use the data somewhere > await qstream.waitForReadableOrFinished(); > } > } > > > > Building reliable, ordered DataChannels on top of streams: > > A reliable, ordered DataChannel can be implemented on top of a single > QuicStream with a simple framing mechanism to insert messages into the > stream. For example, a framing mechanism like the following could be used: > > DataChannel.send writes the following to the stream: > - 2 bits to indicate the type (0x01 == string; 0x10 == binary) > - 2 bits for the number of additional bytes (N) for the length of the > message payload (0x00 = 0, 0x01 = 1, 0x10 = 2, 0x11 = 4) > - 4 + (8 * N) bits for the length of the message (M) > - M bytes for the message > > > Building unreliable or unordered DataChannels: > > To send unordered or unreliable messages over QUIC, multiple QUIC streams > must be used, perhaps one for each message. A DataChannel could be built > which creates a new QUIC stream for each message which writes a data > channel ID as metadata first and then a message write after. However, an > application will typically simply skip this level of abstraction and just > use QuicStreams directly for sending and receiving unreliable or unordered > messages, since it is more simple that way. > > > WHATWG streams: > > WHATWG streams wrap an "underlying source" to create ReadableStreams and > WritableStreams which provide some convenient functionality. We can create > "underlying sources" from QuicStreams with something like this: > > var qstream = quic.createStream(); > new ReadableStream( > { > type: "bytes", > start: function(controller) { > qstream.onclose = () => controller.close(); > }, > pull: async function(controller) { > if (controller.byobRequest) { > var data = controller.byobRequest.view; > await qstream.waitForReadableOrFinished(data.byteLength); > if (qstream.finished) { > let finishedData = qstream.read() > copyArray(finishedData, data); > controller.byobRequest.respond(finishedData.byteLength); > } else { > qstream.readInto(data); > controller.byobRequest.respond(data.byteLength); > } > } else { > await qstream.waitForReadableOrFinished(); > controller.enqueue(qstream.read()); > } > }, > cancel: function() { > qstream.close(); > } > }, > new ByteLengthQueuingStrategy({ highWaterMark: 4096 }) > ); > > new WritableStream( > { > type: "bytes", > start: function (controller) { > qstream.onclose = () => controller.close(); > }, > write: async function(chunk, controller) { > await qstream.waitForWritableAmount(chunk.byteLength); > qstream.write(chunk); > } > close: async function() { > qstream.finish(); > await waitForEvent(qstream.onacked); > qstream.close(); > } > abort: function(reason) { > qstream.close(); > } > }, > new ByteLengthQueuingStrategy({ highWaterMark: 4096 }) > ); > > > Finally, here are the two buffering methods I mentioned that are need to > make the back pressure and buffer work correctly: > > partial interface QuicStream { > // Resolves when the amount can be written or buffered, > // Up to the maxBufferedAmount (== amount if not given) > Promise waitForWritable(unsigned long amount, > optional unsigned long > maxBufferedAmount); > > // Resolves when the amount can be written or buffered, > // Up to the maxBufferedAmount (== amount if not given) > // Will also resolved if the stream finished. > Promise waitForReadableOrFinished( > unsigned long amount, optional unsigned long maxBufferedAmount); > } > > > I think this approach of QUIC streams with data channels and other > abstractions built on top will be flexible, powerful, and about as simple > as it can get. > > > > > > >
Received on Thursday, 13 July 2017 22:34:28 UTC