current commit
260902.10 · quic: reuse TLS pause machinery to drop event deferral & improve 0RTT
'use strict';

// Measures a complete HTTP/3 exchange: establish a session, send one request
// and read the whole response. Run in two modes, so the cost of a resumed
// 0-RTT session can be compared against a full handshake.
//
// The 0-RTT mode needs a session ticket, which can only come from an earlier
// connection. That first connection is made during warmup, outside the
// measured region, so what is timed is only the resumed exchange.

const common = require('../common.js');
const fixtures = require('../../test/common/fixtures');
const { createPrivateKey } = require('crypto');

const bench = common.createBenchmark(main, {
  // '0rtt' resumes from a ticket and sends the request in the very first
  // flight; '1rtt' is a fresh session each time. 0-RTT is listed first so
  // that it is the mode the benchmark CI test exercises.
  mode: ['0rtt', '1rtt'],
  n: [500],
}, { flags: ['--experimental-quic', '--experimental-stream-iter',
             '--no-warnings'] });

async function main({ mode, n }) {
  const { listen, connect } = require('node:quic');
  const { bytes } = require('stream/iter');

  const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
  const cert = fixtures.readKey('agent1-cert.pem');
  const body = new TextEncoder().encode('x'.repeat(256));
  const decoder = new TextDecoder();

  const request = {
    ':method': 'GET',
    ':path': '/',
    ':scheme': 'https',
    ':authority': 'localhost',
  };

  const endpoint = await listen((session) => {
    session.opened.catch(() => {});
    session.closed.catch(() => {});
    session.onstream = (stream) => { stream.closed.catch(() => {}); };
  }, {
    sni: { '*': { keys: [key], certs: [cert] } },
    onheaders() {
      this.sendHeaders({ ':status': '200' });
      this.writer.writeSync(body);
      this.writer.endSync();
    },
    endpoint: {
      maxConnectionsPerHost: 0xFFFF,
      maxConnectionsTotal: 0xFFFF,
      sessionCreationRate: 1_000_000,
      sessionCreationBurst: 1_000_000,
    },
  });

  const address = endpoint.address;
  let received = 0;
  const onheaders = () => { received++; };

  // A full handshake, one request, one response. When resume is supplied the
  // request goes out in the first flight, before the handshake completes.
  async function exchange(resume) {
    const session = await connect(address, {
      servername: 'localhost',
      verifyPeer: 'manual',
      alpn: 'h3',
      ...resume,
    });
    const stream = await session.createBidirectionalStream({
      headers: request,
      onheaders,
    });
    if (resume === undefined) await session.opened;
    const response = decoder.decode(await bytes(stream));
    if (response.length !== body.length) {
      throw new Error(`short response: ${response.length}`);
    }
    session.close();
    await session.closed.catch(() => {});
    return session;
  }

  // Collect a ticket for the 0-RTT mode from a connection that is not timed.
  let resume;
  if (mode === '0rtt') {
    const { promise, resolve } = Promise.withResolvers();
    let ticket;
    let token;
    const session = await connect(address, {
      servername: 'localhost',
      verifyPeer: 'manual',
      alpn: 'h3',
      onsessionticket(value) {
        ticket ??= value;
        if (token !== undefined) resolve();
      },
      onnewtoken(value) {
        token ??= value;
        if (ticket !== undefined) resolve();
      },
    });
    await session.opened;
    await promise;
    session.close();
    await session.closed.catch(() => {});
    resume = { sessionTicket: ticket, token };
  }

  // The timed 0-RTT exchanges deliberately never await session.opened, since
  // waiting for the handshake is exactly what 0-RTT avoids. That leaves no
  // opportunity to notice early data being refused, so check separately -
  // otherwise a ticket the server stopped accepting would quietly turn this
  // into a measurement of the 1-RTT path.
  async function checkEarlyDataAccepted() {
    const session = await connect(address, {
      servername: 'localhost',
      verifyPeer: 'manual',
      alpn: 'h3',
      ...resume,
    });
    const stream = await session.createBidirectionalStream({
      headers: request,
      onheaders,
    });
    const info = await session.opened;
    await bytes(stream);
    session.close();
    await session.closed.catch(() => {});
    if (!info.earlyDataAccepted) {
      throw new Error('0-RTT was not accepted, benchmark would be invalid');
    }
  }

  for (let i = 0; i < 20; i++) await exchange(resume);
  if (mode === '0rtt') await checkEarlyDataAccepted();

  received = 0;
  bench.start();
  for (let i = 0; i < n; i++) await exchange(resume);
  bench.end(n);

  if (received !== n) throw new Error(`missing responses: ${received}/${n}`);
  // The ticket is reused for every iteration, so confirm it was still being
  // accepted at the end of the run and not just at the start.
  if (mode === '0rtt') await checkEarlyDataAccepted();
  await endpoint.close();
}