Re: A proposal for putting QUIC streams into ORTC

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