A proposal for putting QUIC streams into ORTC

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 11:43:36 UTC