Repository: mozilla/neqo
Pull request #2637: ci: Add `cloudflare/quiche` and `aws/s2n-quic` to bench
Author: login: larseggert
Created: 2025-05-15T11:07:31Z | Merged: 2025-05-20T04:20:19Z

@mxinden said that @jrmuizel suggested this, so we can compare against other Rust QUIC stacks.


Diff:
diff --git a/.github/actions/nss/action.yml b/.github/actions/nss/action.yml
index 409cea7683..122ed357c9 100644
--- a/.github/actions/nss/action.yml
+++ b/.github/actions/nss/action.yml
@@ -120,7 +120,7 @@ runs:
       uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
       with:
         path: dist
-        key: nss-${{ inputs.target && inputs.target || runner.os }}-${{ runner.arch }}-${{ inputs.type }}-${{ steps.heads.outputs.nss_head }}-${{ steps.heads.outputs.NSPR_HEAD }}
+        key: nss-${{ inputs.target && inputs.target || runner.os }}-${{ runner.arch }}-${{ inputs.type }}-${{ steps.heads.outputs.nss_head }}-${{ steps.heads.outputs.nspr_head }}
 
     - name: Check if build is needed
       id: check_build
diff --git a/.github/actions/rust/action.yml b/.github/actions/rust/action.yml
index 2da16f639d..cacbf879aa 100644
--- a/.github/actions/rust/action.yml
+++ b/.github/actions/rust/action.yml
@@ -17,12 +17,16 @@ inputs:
   targets:
     description: Comma-separated list of target triples to install for this toolchain
     required: false
+  workspaces:
+    description: Newline-separated list of workspaces
+    required: false
 
 runs:
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      # TODO: Manually upate this, Dependabot will skip it because no tags associated with SHA:
+      uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # zizmor: ignore[stale-action-refs]
       with:
         toolchain: ${{ inputs.version  }}
         components: ${{ inputs.components }}
@@ -83,9 +87,9 @@ runs:
         cache-bin: ${{ runner.environment != 'github-hosted' }}
         cache-all-crates: true
         key: ${{ inputs.targets }}
+        workspaces: ${{ inputs.workspaces }}
         save-if: ${{ github.ref == 'refs/heads/main' }} # Only cache runs from `main`.
 
-
     - name: Set up MSVC (Windows)
       if: ${{ runner.os == 'Windows' }}
       uses: ilammy/msvc-dev-cmd@v1 # zizmor: ignore[unpinned-uses]
diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
index f631d0872c..0ac249918a 100644
--- a/.github/workflows/bench.yml
+++ b/.github/workflows/bench.yml
@@ -8,8 +8,8 @@ on:
 env:
   CARGO_PROFILE_BENCH_BUILD_OVERRIDE_DEBUG: true
   CARGO_PROFILE_RELEASE_DEBUG: true
-  TOOLCHAIN: stable
-  PERF_OPT: record -F4999 --call-graph fp -g
+  RUSTUP_TOOLCHAIN: stable
+  PERF_OPT: record -F2999 --call-graph fp -g
   SCCACHE_CACHE_SIZE: 128G
   SCCACHE_DIRECT: true
   MTU: 1504 # https://github.com/microsoft/msquic/issues/4618
@@ -28,59 +28,86 @@ jobs:
         shell: bash
 
     steps:
-      - name: Checkout neqo
+      - name: Checkout mozilla/neqo
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
+          repository: mozilla/neqo
+          path: neqo
+          submodules: 'recursive'
           persist-credentials: false
+          clean: false
 
-      - name: Checkout msquic
+      - name: Checkout microsoft/msquic
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           repository: microsoft/msquic
-          ref: main
           path: msquic
-          submodules: true
+          submodules: 'recursive'
           persist-credentials: false
+          clean: false
 
       - name: Checkout google/quiche
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
         with:
           repository: google/quiche
-          ref: main
-          path: gquiche
-          submodules: true
+          path: google-quiche
+          submodules: 'recursive'
           persist-credentials: false
+          clean: false
+
+      - name: Checkout cloudflare/quiche
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          repository: cloudflare/quiche
+          path: quiche
+          submodules: 'recursive'
+          persist-credentials: false
+          clean: false
+
+      - name: Checkout aws/s2n-quic
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          repository: aws/s2n-quic
+          path: s2n-quic
+          submodules: 'recursive'
+          persist-credentials: false
+          clean: false
 
       - name: Set PATH and environment
         run: |
           echo "/home/bench/.cargo/bin" >> "${GITHUB_PATH}"
 
       - name: Install Rust
-        uses: ./.github/actions/rust
+        uses: ./neqo/.github/actions/rust
         with:
-          version: $TOOLCHAIN
+          version: ${{ env.RUSTUP_TOOLCHAIN }}
           tools: hyperfine, flamegraph, samply
           token: ${{ secrets.GITHUB_TOKEN }}
+          workspaces: |
+            neqo
+            quiche
+            s2n-quic
 
       - name: Get minimum NSS version
         id: nss-version
-        run: echo "minimum=$(cat neqo-crypto/min_version.txt)" >> "$GITHUB_OUTPUT"
+        run: echo "minimum=$(cat neqo/neqo-crypto/min_version.txt)" >> "$GITHUB_OUTPUT"
 
       - name: Install NSS
         id: nss
-        uses: ./.github/actions/nss
+        uses: ./neqo/.github/actions/nss
         with:
           minimum-version: ${{ steps.nss-version.outputs.minimum }}
 
       - name: Build neqo
         run: |
+          cd neqo
           # See https://github.com/flamegraph-rs/flamegraph for why we append to RUSTFLAGS here.
           export RUSTFLAGS="-C link-arg=-Wl,--no-rosegment, -C force-frame-pointers=yes $RUSTFLAGS"
           echo "RUSTFLAGS=$RUSTFLAGS" >> "$GITHUB_ENV"
-          cargo "+$TOOLCHAIN" bench --locked --workspace --features bench --no-run 2>&1 | tee benches.txt
+          cargo bench --locked --workspace --features bench --no-run 2>&1 | tee benches.txt
           BENCHES=$(grep Executable benches.txt | cut -d\( -f2 | cut -d\) -f1 | tr -s '\n' ' ')
           echo "BENCHES=$BENCHES" >> "$GITHUB_ENV"
-          cargo "+$TOOLCHAIN" build --locked --release --bin neqo-client --bin neqo-server
+          cargo build --locked --release --bin neqo-client --bin neqo-server
 
       - name: Build msquic
         run: |
@@ -91,15 +118,27 @@ jobs:
 
       - name: Build google/quiche
         run: |
-          cd gquiche
-          bazel build -c opt --sandbox_writable_path=/home/bench/.cache/sccache quiche:quic_server quiche:quic_client
+          cd google-quiche
+          bazel build -c opt --copt=-fno-omit-frame-pointer --copt=-g --strip=never --sandbox_writable_path=/home/bench/.cache/sccache quiche:quic_server quiche:quic_client
           bazel shutdown
 
+      - name: Build cloudflare/quiche
+        run: |
+          # We already changed RUSTFLAGS above; this depends on that having happened.
+          cd quiche
+          cargo build --release --bin quiche-client --bin quiche-server # --locked not working
+
+      - name: Build aws/s2n-quic
+        run: |
+          # We already changed RUSTFLAGS above; this depends on that having happened.
+          cd s2n-quic
+          cargo build --release --bin s2n-quic-qns # --locked not working
+
       - name: Download cached main-branch results
         uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
         with:
           path: |
-            target/criterion
+            neqo/target/criterion
             hyperfine
           key: bench-results-${{ runner.name }}-${{ github.sha }}
           restore-keys: bench-results-${{ runner.name }}-
@@ -116,50 +155,66 @@ jobs:
         run: sudo /root/bin/prep.sh
 
       - name: Run cargo bench
+        env:
+          NSS_DB_PATH: ${{ github.workspace }}/neqo/test-fixture/db
         run: |
           sudo ip link set dev lo mtu "$MTU"
-          export NSS_DB_PATH="./test-fixture/db"
-          mkdir binaries
+          mkdir -p binaries
+          cd neqo
           for BENCH in $BENCHES; do
             NAME=$(basename "$BENCH" | cut -d- -f1)
-            cp "$BENCH" binaries/
+            cp "$BENCH" ../binaries/
+            # Run it twice, once without perf for baseline comparison, and once with perf for profiling.
+            # (Perf seems to introduce some variability in the results.)
+            # shellcheck disable=SC2086
+            nice -n -20 setarch --addr-no-randomize cset proc --set=cpu23 --exec \
+              $BENCH -- --bench | tee -a ../results.txt
             # shellcheck disable=SC2086
-            perf $PERF_OPT -o "$NAME.perf" \
-              nice -n -20 setarch --addr-no-randomize cset proc --set=cpu23 --exec "$BENCH" -- --bench --noplot | tee -a results.txt
+            nice -n -20 setarch --addr-no-randomize cset proc --set=cpu23 --exec \
+              perf -- $PERF_OPT -o "$NAME.perf" $BENCH --bench --noplot --discard-baseline
           done
 
       # Compare various configurations of neqo against msquic and google/quiche, and gather perf data
       # during the hyperfine runs.
-      - name: Compare neqo, msquic and google/quiche
+      - name: Compare QUIC implementations
         env:
           HOST: 127.0.0.1
           PORT: 4433
           SIZE: 33554432 # 32 MB
           RUNS: 30
+          NSS_DB_PATH: ${{ github.workspace }}/neqo/test-fixture/db
         run: |
           TMP=$(mktemp -d)
+          mkdir -p binaries
+          touch results.txt
           # Make a cert and key for msquic and google.
           openssl req -nodes -new -x509 -keyout "$TMP/key" -out "$TMP/cert" -subj "/CN=DOMAIN" 2>/dev/null
-          # Make test files for msquic to serve.
+          # Make test files to serve.
           truncate -s "$SIZE" "$TMP/$SIZE"
           BIGSIZE=$(bc -l <<< "$SIZE * $RUNS")
           truncate -s "$BIGSIZE" "$TMP/$BIGSIZE"
           # Define the commands to run for each client and server.
           declare -A client_cmd=(
-            ["neqo"]="target/release/neqo-client -- _cc _pacing --output-dir . _flags -Q 1 https://$HOST:$PORT/$SIZE"
-            ["msquic"]="msquic/build/bin/Release/quicinterop -- -test:D -custom:$HOST -port:$PORT -urls:https://$HOST:$PORT/$SIZE"
-            ["google"]="gquiche/bazel-bin/quiche/quic_client -- --disable_certificate_verification https://$HOST:$PORT/$SIZE > $SIZE"
+            ["neqo"]="neqo/target/release/neqo-client _cc _pacing --output-dir . _flags -Q 1 https://$HOST:$PORT/$SIZE"
+            ["msquic"]="msquic/build/bin/Release/quicinterop -test:D -custom:$HOST -port:$PORT -urls:https://$HOST:$PORT/$SIZE"
+            ["google"]="google-quiche/bazel-bin/quiche/quic_client --disable_certificate_verification https://$HOST:$PORT/$SIZE > $SIZE"
+            ["quiche"]="quiche/target/release/quiche-client --dump-responses . --no-verify https://$HOST:$PORT/$SIZE"
+            ["s2n"]="s2n-quic/target/release/s2n-quic-qns interop client --tls rustls --disable-cert-verification --download-dir . --local-ip $HOST https://$HOST:$PORT/$SIZE"
           )
           declare -A server_cmd=(
-            ["neqo"]="target/release/neqo-server -- _cc _pacing _flags -Q 1 $HOST:$PORT"
-            ["msquic"]="msquic/build/bin/Release/quicinteropserver -- -root:$TMP -listen:$HOST -port:$PORT -file:$TMP/cert -key:$TMP/key -noexit"
-            ["google"]="gquiche/bazel-bin/quiche/quic_server -- --generate_dynamic_responses --port $PORT --certificate_file $TMP/cert --key_file $TMP/key"
+            ["neqo"]="neqo/target/release/neqo-server _cc _pacing _flags -Q 1 $HOST:$PORT"
+            ["msquic"]="msquic/build/bin/Release/quicinteropserver -root:$TMP -listen:$HOST -port:$PORT -file:$TMP/cert -key:$TMP/key -noexit"
+            ["google"]="google-quiche/bazel-bin/quiche/quic_server --generate_dynamic_responses --port $PORT --certificate_file $TMP/cert --key_file $TMP/key"
+            ["quiche"]="quiche/target/release/quiche-server --root $TMP --listen $HOST:$PORT --cert $TMP/cert --key $TMP/key"
+            ["s2n"]="s2n-quic/target/release/s2n-quic-qns interop server --www-dir $TMP --certificate $TMP/cert --private-key $TMP/key --ip $HOST --port $PORT"
           )
           # Flags to pass to neqo when it runs against another implementation.
           declare -A neqo_flags=(
             ["neqo"]=""
             ["msquic"]="-a hq-interop"
             ["google"]=""
+            ["quiche"]=""
+            ["s2n"]="-a hq-interop"
           )
 
           # Replace various placeholders in the commands with the actual values.
@@ -172,10 +227,11 @@ jobs:
             if [[ "$cc" != "" ]]; then
               CMD=${CMD//_cc/--cc $cc}
               EXT="-$cc"
+            else
+              CMD=${CMD//_cc/}
             fi
             if [[ "$pacing" == "on" ]]; then
               CMD=${CMD//_pacing/}
-              EXT="$EXT-pacing"
             else
               CMD=${CMD//_pacing/--no-pacing}
               EXT="$EXT-nopacing"
@@ -197,81 +253,95 @@ jobs:
 
           # See https://github.com/microsoft/msquic/issues/4618#issuecomment-2422611592
           sudo ip link set dev lo mtu "$MTU"
-          for server in neqo google msquic; do
+          for server in "${!server_cmd[@]}"; do
             cp -v "$(echo "${server_cmd[$server]}" | cut -f1 -d' ')" binaries/
-            for client in neqo google msquic; do
-              # Do not run msquic against google-quiche; the latter only supports H3.
-              # Also, we are not interested in google as the server, or msquic as the client, except against themselves.
-              if [[ "$client" == "google" && "$server" == "msquic" ||
-                    "$client" == "msquic" && "$server" == "google" ||
-                    "$client" != "google" && "$server" == "google" ||
-                    "$client" == "msquic" && "$server" != "msquic" ]]; then
+            for client in "${!client_cmd[@]}"; do
+              # Do not run msquic and s2n-quic against google-quiche and quiche; the latter only support H3.
+              if [[ "$client" != "$server" && "$client" != "neqo" && "$server" != "neqo" ]]; then
+                echo "Skipping $client vs. $server"
                 continue
               fi
               cp -v "$(echo "${client_cmd[$client]}" | cut -f1 -d' ')" binaries/
               # google-quiche binaries don't have write permissions by default?!
               chmod u+w binaries/*
               # google and msquic don't let us configure the congestion control or pacing.
-              if [[ "$client" != "neqo" && "$server" != "neqo" ]]; then
-                cc_opt=("")
-                pacing_opt=("")
-              else
+              # TODO: Support quiche and s2n-quic here.
+              if [[ "$client" != "neqo" && "$server" == "neqo" || "$client" == "neqo" && "$server" != "neqo" ]]; then
+                cc_opt=("cubic")
+                pacing_opt=("on")
+              elif [[ "$client" == "neqo" && "$server" == "neqo" ]]; then
                 cc_opt=("reno" "cubic")
                 pacing_opt=("on" "")
+              else
+                cc_opt=("")
+                pacing_opt=("")
               fi
               for cc in "${cc_opt[@]}"; do
                 for pacing in "${pacing_opt[@]}"; do
                   # Make a tag string for this test, for the results. Highlight lines we care about.
+                  BOLD=""
                   if [[ "$client" == "neqo" && "$server" == "neqo" && "$cc" == "cubic" && "$pacing" == "on" ||
-                        "$client" == "msquic" && "$server" == "msquic" ||
-                        "$client" == "google" && "$server" == "google" ]]; then
-                    TAG="**$client**,**$server**,${cc:+**}$cc${cc:+**},${pacing:+**}$pacing${pacing:+**}"
-                  else
-                    TAG="$client,$server,$cc,$pacing"
+                        "$client" == "$server" && "$client" != "neqo" && "$server" != "neqo" ]]; then
+                    BOLD="**"
                   fi
+                  TAG="$BOLD$client vs. $server${cc:+ (}$cc${pacing:+, paced}${cc:+)}$BOLD"
                   echo "Running benchmarks for $TAG" | tee -a comparison.txt
                   transmogrify "${server_cmd[$server]}" "$cc" "$pacing" "${neqo_flags[$client]}"
                   FILENAME="$client-$server$EXT"
+                  echo "Server: $CMD"
+                  SERVER_CMD="$CMD"
                   # shellcheck disable=SC2086
-                  perf $PERF_OPT -o "$FILENAME.server.perf" nice -n -20 setarch --addr-no-randomize cset proc --set=cpu2 --exec $CMD &
+                  nice -n -20 setarch --addr-no-randomize cset proc --set=cpu2 --exec ${CMD/ / -- } &
                   transmogrify "${client_cmd[$client]}" "$cc" "$pacing" "${neqo_flags[$server]}"
+                  echo "Client: $CMD"
                   # shellcheck disable=SC2086
-                  hyperfine --command-name "$TAG" --time-unit millisecond  \
-                    --export-json "hyperfine/$FILENAME.json" \
-                    --export-markdown "hyperfine/$FILENAME.md" \
-                    --output null --warmup 5 --runs "$RUNS" --prepare "sleep 1" \
-                    "nice -n -20 setarch --addr-no-randomize cset proc --set=cpu3 --exec $CMD" |
-                  tee -a comparison.txt
+                  nice -n -20 setarch --addr-no-randomize cset proc --set=cpu3 --exec \
+                    hyperfine -- --command-name "$TAG" --time-unit millisecond  \
+                      --export-json "hyperfine/$FILENAME.json" \
+                      --export-markdown "hyperfine/$FILENAME.md" \
+                      --output null --warmup 5 --min-runs "$RUNS" --setup "sleep 1" "$CMD" |
+                    tee -a comparison.txt
                   echo >> comparison.txt
                   # Sanity check the size of the last retrieved file.
                   # google/quiche outputs the HTTP header, too, so we can't just check for -eq.
                   [ "$(wc -c <"$SIZE")" -ge "$SIZE" ] || exit 1
+                  # pkill only matches on the first 15 characters of the command?!
+                  sleep 1 && pkill -e "$(basename "$(echo "${server_cmd[$server]}" | cut -f1 -d' ')" | cut -c1-15)"
 
-                  # Do a longer client run with perf separately. We used to just wrap the hyperfine command above in perf,
+                  # Do a longer run with perf separately. We used to just wrap the hyperfine command above in perf,
                   # but that uses different processes for the individual runs, and there is apparently no way to merge
                   # the perf profiles of those different runs.
+                  echo "Perf run server: $SERVER_CMD"
+                  # shellcheck disable=SC2086
+                  nice -n -20 setarch --addr-no-randomize cset proc --set=cpu2 --exec \
+                    perf -- $PERF_OPT -o "$FILENAME.server.perf" $SERVER_CMD &
+                  sleep 1
                   CMD=${CMD//$SIZE/$BIGSIZE}
+                  echo "Perf run client: $CMD"
                   # shellcheck disable=SC2086
-                  perf $PERF_OPT -o "$FILENAME.client.perf" \
-                    nice -n -20 setarch --addr-no-randomize cset proc --set=cpu3 --exec $CMD > /dev/null 2>&1
+                  nice -n -20 setarch --addr-no-randomize cset proc --set=cpu3 --exec \
+                    perf -- $PERF_OPT -o "$FILENAME.client.perf" $CMD > /dev/null 2>&1
                   # pkill only matches on the first 15 characters of the command?!
-                  pkill -e "$(basename "$(echo "${server_cmd[$server]}" | cut -f1 -d' ')" | cut -c1-15)"
+                  sleep 1 && pkill -e "$(basename "$(echo "${server_cmd[$server]}" | cut -f1 -d' ')" | cut -c1-15)"
 
                   grep -Ev '^\|(:| Command)' < "hyperfine/$FILENAME.md" | \
-                    sed -E 's/`//g; s/,/ \| /g;' | cut -f1-8 -d\| | tr -d '\n' >> steps.md
+                    sed -E 's/`//g;' | cut -f1-5 -d\| | tr -d '\n' >> steps.md
+
+                  # Compute throughput in MiB/s.
+                  RESULT="hyperfine/$FILENAME.json"
+                  MEAN=$(jq -r '.results[0].mean' "$RESULT")
+                  RANGE=$(grep -Po '^.*± *\K.*' < "hyperfine/$FILENAME.md" | cut -d' ' -f1)
+                  MIBS=$(bc -l <<< "($SIZE / 1048576) / $MEAN")
+                  MIBS_RANGE=$(bc -l <<< "($SIZE / 1048576) / $RANGE")
+                  printf "| %.1f ± %.1f " "$MIBS" "$MIBS_RANGE"  >> steps.md
+                  BASELINE="hyperfine-main/$FILENAME.json"
 
                   # Figure out if any performance difference to `main` is statistically relevant, and indicate that.
-                  BASELINE="hyperfine-main/$FILENAME.json"
                   if [ -e "$BASELINE" ]; then
-                    RESULT="hyperfine/$FILENAME.json"
                     BASELINE_MEAN=$(jq -r '.results[0].mean' "$BASELINE")
-                    MEAN=$(jq -r '.results[0].mean' "$RESULT")
                     # Even though we tell hyperfine to use milliseconds, it still outputs in seconds when dumping to JSON.
                     DELTA=$(bc -l <<< "($MEAN - $BASELINE_MEAN) * 1000")
                     PERCENT=$(bc -l <<< "(($MEAN - $BASELINE_MEAN) / $BASELINE_MEAN) * 100")
-                    MIBS=$(bc -l <<< "($SIZE / 1048576) / $MEAN")
-                    RANGE=$(grep -Po '^.*± *\K.*' < "hyperfine/$FILENAME.md" | cut -d' ' -f1)
-                    MIBS_RANGE=$(bc -l <<< "($SIZE / 1048576) / $RANGE")
 
                     # If a performance change is statistically significant, highlight it.
                     jq -r '.results[0].times[]' "$BASELINE" > baseline.txt
@@ -291,8 +361,7 @@ jobs:
                       SYMBOL=""
                       FORMAT=""
                     fi
-                    printf "| %.1f ± %.1f | %s %s%.1f%s | %s%.1f%%%s |\n" \
-                      "$MIBS" "$MIBS_RANGE" "$SYMBOL" "$FORMAT" "$DELTA" "$FORMAT" "$FORMAT" "$PERCENT" "$FORMAT" >> steps.md
+                    printf "| %s %s%.1f%s | %s%.1f%%%s |\n" "$SYMBOL" "$FORMAT" "$DELTA" "$FORMAT" "$FORMAT" "$PERCENT" "$FORMAT" >> steps.md
                   else
                     echo No cached baseline from main found.
                     echo '| :question: | :question: |' >> steps.md
@@ -304,12 +373,12 @@ jobs:
 
           # Make a single results table.
           {
-            echo "Transfer of $SIZE bytes over loopback, $RUNS runs. All unit-less numbers are in milliseconds."
+            echo "Transfer of $SIZE bytes over loopback, min. $RUNS runs. All unit-less numbers are in milliseconds."
             echo
             # shellcheck disable=SC2016
-            echo '| Client | Server | CC | Pacing | Mean ± σ | Min | Max | MiB/s ± σ | Δ `main` | Δ `main` |'
-            echo '|:---|:---|:---|---|---:|---:|---:|---:|---:|---:|'
-            cat steps.md
+            echo '| Client vs. server (params) | Mean ± σ | Min | Max | MiB/s ± σ | Δ `main` | Δ `main` |'
+            echo '|:---|---:|---:|---:|---:|---:|---:|'
+            sed -E -e 's/^\| \*\*/\| /' steps.md | sort | sed -E -e 's/^\| ([^\|]+\*\* \|)/\| \*\*\1/'
           } > comparison.md
           rm -r "$TMP"
 
@@ -325,12 +394,10 @@ jobs:
         run: |
           for f in *.perf; do
             # Convert for profiler.firefox.com
-            samply import "$f" -o "$f.samply.json.gz" --save-only --unstable-presymbolicate &
+            samply import "$f" -o "$f.samply.json.gz" --save-only --unstable-presymbolicate
             # Generate flamegraphs
-            flamegraph --perfdata "$f" --palette rust -o "${f//.perf/.svg}" &
+            flamegraph --perfdata "$f" --palette rust -o "${f//.perf/.svg}"
           done
-          wait
-          rm neqo.svg
 
       - name: Format results as Markdown
         id: results
@@ -339,7 +406,7 @@ jobs:
             echo "### Benchmark results"
             echo
           } > results.md
-          SHA=$(cat target/criterion/baseline-sha.txt || true)
+          SHA=$(cat neqo/target/criterion/baseline-sha.txt || true)
           if [ -n "$SHA" ]; then
             {
               echo "Performance differences relative to $SHA."
@@ -357,7 +424,7 @@ jobs:
           {
             echo
             echo "### Client/server transfer results"
-            SHA=$(cat target/criterion/baseline-sha.txt || true)
+            SHA=$(cat neqo/target/criterion/baseline-sha.txt || true)
             if [ -n "$SHA" ]; then
               cat sha.md >> results.md
             fi
@@ -367,14 +434,14 @@ jobs:
 
       - name: Remember main-branch push URL
         if: ${{ github.ref == 'refs/heads/main' }}
-        run: echo "${{ github.sha }}" > target/criterion/baseline-sha.txt
+        run: echo "${{ github.sha }}" > neqo/target/criterion/baseline-sha.txt
 
       - name: Cache main-branch results
         if: ${{ github.ref == 'refs/heads/main' }}
         uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
         with:
           path: |
-            target/criterion
+            neqo/target/criterion
             hyperfine
           key: bench-results-${{ runner.name }}-${{ github.sha }}
 
@@ -390,13 +457,13 @@ jobs:
             *.txt
             *.md
             results.*
-            target/criterion
+            neqo/target/criterion
             hyperfine
             binaries
           compression-level: 9
 
       - name: Export PR comment data
-        uses: ./.github/actions/pr-comment-data-export
+        uses: ./neqo/.github/actions/pr-comment-data-export
         with:
           name: ${{ github.workflow }}
           contents: results.md
@@ -410,3 +477,9 @@ jobs:
           else
             echo "No performance regression detected."
           fi
+
+      - name: Remove benchmark artifacts
+        if: always()
+        run: |
+          rm -- * || true
+          rm -r -- binaries comment-data hyperfine hyperfine-main || true
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index f1fd1b41b2..8199aa122f 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -163,7 +163,7 @@ jobs:
 
   bench:
     needs: [check]
-    if: ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || github.event.inputs.run_benchmarks) && github.actor != 'dependabot[bot]' }}
+    if: ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || github.event.inputs.run_benchmarks) && github.actor != 'dependabot[bot]' && github.event_name != 'merge_group' }}
     uses: ./.github/workflows/bench.yml
 
   check-android:


Reviews:
user: login: larseggert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T15:10:34Z
user: login: larseggert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T15:11:41Z
user: login: larseggert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T15:30:44Z
user: login: mxinden
body: :rocket: thanks!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T16:54:57Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T17:52:38Z
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-17T07:30:04Z

Review comments:
diff_hunk: @@ -22,7 +22,7 @@ runs:
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      uses: dtolnay/rust-toolchain@master # zizmor: ignore[unpinned-uses]
path: .github/actions/rust/action.yml
user: login: larseggert
body: Since I had a lot of time to wait for the CI runs during debugging, I noticed some unrelated issues and fixed them. This is one of them.
created_at: 2025-05-16T15:10:34Z
author_association: COLLABORATOR
diff_hunk: @@ -22,7 +22,7 @@ runs:
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      uses: dtolnay/rust-toolchain@master # zizmor: ignore[unpinned-uses]
path: .github/actions/rust/action.yml
user: login: larseggert
body: Dependabot doesn't update SHAs that aren't associated with tags, and `dtolnay/rust-toolchain` doesn't tag any releases, so we were stuck on an old version.
created_at: 2025-05-16T15:11:40Z
author_association: COLLABORATOR
diff_hunk: @@ -120,7 +120,7 @@ runs:
       uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
       with:
         path: dist
-        key: nss-${{ inputs.target && inputs.target || runner.os }}-${{ runner.arch }}-${{ inputs.type }}-${{ steps.heads.outputs.nss_head }}-${{ steps.heads.outputs.NSPR_HEAD }}
+        key: nss-${{ inputs.target && inputs.target || runner.os }}-${{ runner.arch }}-${{ inputs.type }}-${{ steps.heads.outputs.nss_head }}-${{ steps.heads.outputs.nspr_head }}
path: .github/actions/nss/action.yml
user: login: larseggert
body: This is another unrelated change. Not sure if this even matters (are `outputs` case sensitive?) but no harm.
created_at: 2025-05-16T15:30:44Z
author_association: COLLABORATOR
diff_hunk: @@ -22,7 +22,7 @@ runs:
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      uses: dtolnay/rust-toolchain@master # zizmor: ignore[unpinned-uses]
path: .github/actions/rust/action.yml
user: login: mxinden
body: Preference for a hash, even if it idoesn't auto-update. In my eyes, the more deterministic our CI is the better.
created_at: 2025-05-16T16:54:48Z
author_association: MEMBER
diff_hunk: @@ -22,7 +22,8 @@
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      # TODO: Manually upate this, Dependabot will skip it because no tags associated with SHA:
+      uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b
path: .github/actions/rust/action.yml
user: login: github-advanced-security[bot]
body: this action

[Show more details](https://github.com/mozilla/neqo/security/code-scanning/26)
created_at: 2025-05-16T17:52:38Z
author_association: NONE
diff_hunk: @@ -22,7 +22,8 @@
   using: composite
   steps:
     - name: Install Rust
-      uses: dtolnay/rust-toolchain@21dc36fb71dd22e3317045c0c31a3f4249868b17 # master
+      # TODO: Manually upate this, Dependabot will skip it because no tags associated with SHA:
+      uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # zizmor: ignore[unpinned-uses]
path: .github/actions/rust/action.yml
user: login: github-advanced-security[bot]
body: this action

[Show more details](https://github.com/mozilla/neqo/security/code-scanning/27)
created_at: 2025-05-17T07:30:04Z
author_association: NONE

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-15T11:46:57Z
body: ### Failed Interop Tests
None :question:
<details><summary>All results</summary>

### Succeeded Interop Tests
None :question:
### Unsupported Interop Tests
None :question:

</details>

<!-- thollander/actions-comment-pull-request "QNS-comment" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-15T15:25:12Z
body: ### Benchmark results

Performance differences relative to f24fab6b77cdf745cb6e8faad42fe01419da6c1e.

<details><summary>1-conn/1-100mb-resp/mtu-1504 (aka. Download)/client: No change in performance detected.</summary><pre>
       time:   [696.50 ms 701.90 ms 707.44 ms]
       thrpt:  [141.36 MiB/s 142.47 MiB/s 143.58 MiB/s]
change:
       time:   [-0.6459% <b>+0.5228%</b> +1.6950%] (p = 0.37 > 0.05)
       thrpt:  [-1.6667% <b>-0.5201%</b> +0.6501%]
       
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild</pre></details>
<details><summary>1-conn/10_000-parallel-1b-resp/mtu-1504 (aka. RPS)/client: No change in performance detected.</summary><pre>
       time:   [312.64 ms 314.00 ms 315.37 ms]
       thrpt:  [31.709 Kelem/s 31.848 Kelem/s 31.986 Kelem/s]
change:
       time:   [-0.8145% <b>-0.1645%</b> +0.4833%] (p = 0.64 > 0.05)
       thrpt:  [-0.4810% <b>+0.1648%</b> +0.8212%]
</pre></details>
<details><summary>1-conn/1-1b-resp/mtu-1504 (aka. HPS)/client: No change in performance detected.</summary><pre>
       time:   [25.516 ms 25.678 ms 25.847 ms]
       thrpt:  [38.689  elem/s 38.945  elem/s 39.191  elem/s]
change:
       time:   [-0.8334% <b>+0.0063%</b> +0.9311%] (p = 0.99 > 0.05)
       thrpt:  [-0.9225% <b>-0.0063%</b> +0.8404%]
       
Found 4 outliers among 100 measurements (4.00%)
  3 (3.00%) high mild
  1 (1.00%) high severe</pre></details>
<details><summary>1-conn/1-100mb-req/mtu-1504 (aka. Upload)/client: No change in performance detected.</summary><pre>
       time:   [2.1306 s 2.1532 s 2.1765 s]
       thrpt:  [45.945 MiB/s 46.443 MiB/s 46.936 MiB/s]
change:
       time:   [-0.6410% <b>+1.0288%</b> +2.6471%] (p = 0.22 > 0.05)
       thrpt:  [-2.5788% <b>-1.0183%</b> +0.6452%]
       
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild</pre></details>
<details><summary>decode 4096 bytes, mask ff: No change in performance detected.</summary><pre>
       time:   [11.821 µs 11.863 µs 11.911 µs]
       change: [-0.3065% <b>+0.2129%</b> +0.7421%] (p = 0.43 > 0.05)
       
Found 14 outliers among 100 measurements (14.00%)
  1 (1.00%) low severe
  1 (1.00%) low mild
  12 (12.00%) high severe</pre></details>
<details><summary>decode 1048576 bytes, mask ff: No change in performance detected.</summary><pre>
       time:   [3.0264 ms 3.0358 ms 3.0470 ms]
       change: [-0.4165% <b>+0.0730%</b> +0.5598%] (p = 0.75 > 0.05)
       
Found 9 outliers among 100 measurements (9.00%)
  9 (9.00%) high severe</pre></details>
<details><summary>decode 4096 bytes, mask 7f: No change in performance detected.</summary><pre>
       time:   [19.661 µs 19.697 µs 19.741 µs]
       change: [-1.0922% <b>-0.4680%</b> +0.0336%] (p = 0.11 > 0.05)
       
Found 16 outliers among 100 measurements (16.00%)
  6 (6.00%) low severe
  1 (1.00%) high mild
  9 (9.00%) high severe</pre></details>
<details><summary>decode 1048576 bytes, mask 7f: No change in performance detected.</summary><pre>
       time:   [5.1651 ms 5.1780 ms 5.1927 ms]
       change: [-0.3089% <b>+0.0434%</b> +0.4393%] (p = 0.81 > 0.05)
       
Found 13 outliers among 100 measurements (13.00%)
  13 (13.00%) high severe</pre></details>
<details><summary>decode 4096 bytes, mask 3f: No change in performance detected.</summary><pre>
       time:   [6.2071 µs 6.2254 µs 6.2495 µs]
       change: [-1.4031% <b>-0.6833%</b> -0.0291%] (p = 0.05 > 0.05)
       
Found 8 outliers among 100 measurements (8.00%)
  3 (3.00%) low mild
  2 (2.00%) high mild
  3 (3.00%) high severe</pre></details>
<details><summary>decode 1048576 bytes, mask 3f: No change in performance detected.</summary><pre>
       time:   [1.7583 ms 1.7598 ms 1.7627 ms]
       change: [-0.3792% <b>-0.0739%</b> +0.1694%] (p = 0.68 > 0.05)
       
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high severe</pre></details>
<details><summary>1000 streams of 1 bytes/multistream: No change in performance detected.</summary><pre>
       time:   [24.440 ms 24.466 ms 24.493 ms]
       change: [-0.0295% <b>+0.1252%</b> +0.2767%] (p = 0.11 > 0.05)
       
Found 63 outliers among 500 measurements (12.60%)
  62 (12.40%) high mild
  1 (0.20%) high severe</pre></details>
<details open><summary>1000 streams of 1000 bytes/multistream: :green_heart: <b>Performance has improved.</b></summary><pre>
       time:   [142.28 ms 142.32 ms 142.36 ms]
       change: [-2.6467% <b>-2.6067%</b> -2.5670%] (p = 0.00 < 0.05)
       
Found 11 outliers among 500 measurements (2.20%)
  7 (1.40%) high mild
  4 (0.80%) high severe</pre></details>
<details><summary>coalesce_acked_from_zero 1+1 entries: Change within noise threshold.</summary><pre>
       time:   [88.120 ns 88.415 ns 88.717 ns]
       change: [-1.2017% <b>-0.5906%</b> -0.0693%] (p = 0.04 < 0.05)
       
Found 8 outliers among 100 measurements (8.00%)
  5 (5.00%) high mild
  3 (3.00%) high severe</pre></details>
<details><summary>coalesce_acked_from_zero 3+1 entries: Change within noise threshold.</summary><pre>
       time:   [106.05 ns 106.40 ns 106.77 ns]
       change: [-0.8384% <b>-0.5002%</b> -0.1954%] (p = 0.00 < 0.05)
       
Found 14 outliers among 100 measurements (14.00%)
  1 (1.00%) high mild
  13 (13.00%) high severe</pre></details>
<details><summary>coalesce_acked_from_zero 10+1 entries: Change within noise threshold.</summary><pre>
       time:   [105.56 ns 106.04 ns 106.59 ns]
       change: [-0.9839% <b>-0.5305%</b> -0.0608%] (p = 0.02 < 0.05)
       
Found 16 outliers among 100 measurements (16.00%)
  2 (2.00%) low severe
  2 (2.00%) low mild
  4 (4.00%) high mild
  8 (8.00%) high severe</pre></details>
<details><summary>coalesce_acked_from_zero 1000+1 entries: No change in performance detected.</summary><pre>
       time:   [88.628 ns 89.991 ns 93.034 ns]
       change: [-1.1148% <b>+6.4925%</b> +20.870%] (p = 0.59 > 0.05)
       
Found 9 outliers among 100 measurements (9.00%)
  3 (3.00%) high mild
  6 (6.00%) high severe</pre></details>
<details><summary>RxStreamOrderer::inbound_frame(): Change within noise threshold.</summary><pre>
       time:   [110.65 ms 110.72 ms 110.79 ms]
       change: [+0.2957% <b>+0.3767%</b> +0.4595%] (p = 0.00 < 0.05)
       
Found 12 outliers among 100 measurements (12.00%)
  1 (1.00%) low severe
  5 (5.00%) low mild
  6 (6.00%) high mild</pre></details>
<details><summary>SentPackets::take_ranges: No change in performance detected.</summary><pre>
       time:   [8.1807 µs 8.4017 µs 8.6107 µs]
       change: [-4.2522% <b>-1.1493%</b> +1.8315%] (p = 0.47 > 0.05)
       
Found 18 outliers among 100 measurements (18.00%)
  2 (2.00%) low severe
  12 (12.00%) low mild
  1 (1.00%) high mild
  3 (3.00%) high severe</pre></details>
<details><summary>transfer/pacing-false/varying-seeds: No change in performance detected.</summary><pre>
       time:   [35.096 ms 35.164 ms 35.232 ms]
       change: [-0.2234% <b>+0.0416%</b> +0.3211%] (p = 0.77 > 0.05)
</pre></details>
<details><summary>transfer/pacing-true/varying-seeds: Change within noise threshold.</summary><pre>
       time:   [35.350 ms 35.441 ms 35.534 ms]
       change: [-1.9939% <b>-1.6610%</b> -1.3085%] (p = 0.00 < 0.05)
</pre></details>
<details><summary>transfer/pacing-false/same-seed: Change within noise threshold.</summary><pre>
       time:   [34.625 ms 34.667 ms 34.710 ms]
       change: [-1.5241% <b>-1.3468%</b> -1.1669%] (p = 0.00 < 0.05)
       
Found 5 outliers among 100 measurements (5.00%)
  5 (5.00%) high mild</pre></details>
<details><summary>transfer/pacing-true/same-seed: Change within noise threshold.</summary><pre>
       time:   [36.336 ms 36.414 ms 36.495 ms]
       change: [-1.1899% <b>-0.9032%</b> -0.6295%] (p = 0.00 < 0.05)
</pre></details>

### Client/server transfer results
Performance differences relative to f24fab6b77cdf745cb6e8faad42fe01419da6c1e.

Transfer of 33554432 bytes over loopback, min. 30 runs. All unit-less numbers are in milliseconds.

| Client vs. server (params) | Mean ± σ | Min | Max | MiB/s ± σ | Δ `main` | Δ `main` |
|:---|---:|---:|---:|---:|---:|---:|
| **google vs. google** | 498.9 ± 24.9 | 481.3 | 584.8 | 64.1 ± 1.3 |  1.3 | 0.3% |
| google vs. neqo (cubic, paced) | 807.0 ± 191.9 | 501.5 | 1119.3 | 39.7 ± 0.2 |  13.8 | 1.7% |
| **msquic vs. msquic** | 160.5 ± 74.6 | 122.9 | 392.1 | 199.4 ± 0.4 |  21.1 | 15.1% |
| msquic vs. neqo (cubic, paced) | 328.4 ± 76.5 | 244.8 | 589.0 | 97.4 ± 0.4 |  22.9 | 7.5% |
| neqo vs. google (cubic, paced) | 786.1 ± 44.6 | 743.6 | 1011.1 | 40.7 ± 0.7 |  12.2 | 1.6% |
| neqo vs. msquic (cubic, paced) | 194.9 ± 10.1 | 186.4 | 222.4 | 164.2 ± 3.2 |  2.1 | 1.1% |
| neqo vs. neqo (cubic) | 265.8 ± 95.8 | 224.9 | 669.4 | 120.4 ± 0.3 |  27.2 | 11.4% |
| **neqo vs. neqo (cubic, paced)** | 235.7 ± 7.6 | 225.3 | 255.0 | 135.7 ± 4.2 |  -2.5 | -1.0% |
| neqo vs. neqo (reno) | 231.9 ± 4.8 | 225.6 | 246.6 | 138.0 ± 6.7 | :green_heart: **-7.3** | **-3.0%** |
| neqo vs. neqo (reno, paced) | 238.1 ± 17.2 | 227.7 | 319.2 | 134.4 ± 1.9 |  0.4 | 0.2% |
| neqo vs. quiche (cubic, paced) | 269.1 ± 102.9 | 226.8 | 720.2 | 118.9 ± 0.3 |  30.0 | 12.6% |
| neqo vs. s2n (cubic, paced) | 245.6 ± 19.8 | 233.3 | 331.8 | 130.3 ± 1.6 |  -1.4 | -0.5% |
| quiche vs. neqo (cubic, paced) | 368.2 ± 17.8 | 347.0 | 410.9 | 86.9 ± 1.8 |  4.2 | 1.2% |
| **quiche vs. quiche** | 179.7 ± 5.2 | 173.5 | 195.3 | 178.1 ± 6.2 | :green_heart: **-34.8** | **-16.2%** |
| s2n vs. neqo (cubic, paced) | 297.1 ± 23.9 | 278.9 | 368.0 | 107.7 ± 1.3 |  5.3 | 1.8% |
| **s2n vs. s2n** | 237.2 ± 11.6 | 226.9 | 265.5 | 134.9 ± 2.8 |  -6.8 | -2.8% |

[:arrow_down: Download logs](https://github.com/mozilla/neqo/actions/runs/15128785393/artifacts/3157061181)

<!-- thollander/actions-comment-pull-request "CI-comment" -->
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-17T06:44:36Z
body: ## [Codecov](https://app.codecov.io/gh/mozilla/neqo/pull/2637?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.98%. Comparing base [(`2880d94`)](https://app.codecov.io/gh/mozilla/neqo/commit/2880d941f62fbcd63704e6fe742f612665a627f6?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) to head [(`455690a`)](https://app.codecov.io/gh/mozilla/neqo/commit/455690a580f7d4df437e861614dd062e9ebfe099?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla).
> Report is 4 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2637      +/-   ##
==========================================
- Coverage   95.55%   92.98%   -2.57%     
==========================================
  Files         115      115              
  Lines       37396    37396              
  Branches    37396    37396              
==========================================
- Hits        35733    34772     -961     
- Misses       1657     1726      +69     
- Partials        6      898     +892     
```

| [Components](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=components&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | Coverage Δ | |
|---|---|---|
| [neqo-common](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `96.11% <ø> (-1.06%)` | :arrow_down: |
| [neqo-crypto](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `80.53% <ø> (-9.93%)` | :arrow_down: |
| [neqo-http3](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `92.66% <ø> (-1.84%)` | :arrow_down: |
| [neqo-qpack](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `94.21% <ø> (-2.08%)` | :arrow_down: |
| [neqo-transport](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `94.18% <ø> (-2.38%)` | :arrow_down: |
| [neqo-udp](https://app.codecov.io/gh/mozilla/neqo/pull/2637/components?src=pr&el=component&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mozilla) | `82.96% <ø> (-7.70%)` | :arrow_down: |

</details>
author_association: NONE