Skip to content

Alt-Svc breaks Happy Eyeballs v2: Both ballers use QUIC instead of QUIC+TCP fallback #19740

@yushicheng7788

Description

@yushicheng7788

I did this

Description

When Alt-Svc suggests HTTP/3 upgrade, curl's Happy Eyeballs v2 implementation incorrectly creates two QUIC connections instead of one QUIC connection with one TCP fallback connection.

Environment

curl version: 8.12.1
Platform: macOS (likely affects all platforms)
Build options: HTTP/3 enabled with OpenSSL, nghttp2 and nghttp3
Protocol: HTTPS with Alt-Svc enabled

Cpp Demo

/*
 * test_happy_eyeballs.c
 * 
 * Test program for curl Happy Eyeballs v2 with Alt-Svc
 * Tests QUIC (HTTP/3) with TCP (HTTP/2) fallback
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <curl/curl.h>

/* Callback to capture response headers */
static size_t header_callback(char *buffer, size_t size, size_t nitems, void *userdata) {
    size_t numbytes = size * nitems;
    printf("< %.*s", (int)numbytes, buffer);
    return numbytes;
}

/* Callback to capture response body */
static size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata) {
    size_t numbytes = size * nmemb;
    /* Just count bytes, don't print body */
    (*(size_t *)userdata) += numbytes;
    return numbytes;
}

/* Debug callback to see connection details */
static int debug_callback(CURL *handle, curl_infotype type,
                          char *data, size_t size, void *userptr) {
    const char *text;
    (void)handle;
    (void)userptr;

    switch(type) {
    case CURLINFO_TEXT:
        fprintf(stderr, "* %.*s", (int)size, data);
        return 0;
    case CURLINFO_HEADER_OUT:
        text = "=> Send header";
        break;
    case CURLINFO_DATA_OUT:
        text = "=> Send data";
        break;
    case CURLINFO_SSL_DATA_OUT:
        text = "=> Send SSL data";
        break;
    case CURLINFO_HEADER_IN:
        text = "<= Recv header";
        break;
    case CURLINFO_DATA_IN:
        text = "<= Recv data";
        break;
    case CURLINFO_SSL_DATA_IN:
        text = "<= Recv SSL data";
        break;
    default:
        return 0;
    }

    /* Only print non-data info */
    if(type != CURLINFO_DATA_IN && type != CURLINFO_DATA_OUT &&
       type != CURLINFO_SSL_DATA_IN && type != CURLINFO_SSL_DATA_OUT) {
        fprintf(stderr, "%s, %zu bytes\n", text, size);
    }
    return 0;
}

int main(int argc, char *argv[]) {
    CURL *curl;
    CURLcode res;
    const char *url;
    const char *altsvc_file = "alt-svc.txt";
    size_t body_size = 0;
    long http_version = 0;
    char *effective_url = NULL;

    /* Get URL from command line or use default */
    if(argc > 1) {
        url = argv[1];
    } else {
        url = "https://cloudflare.com";
    }

    /* Check if custom alt-svc file specified */
    if(argc > 2) {
        altsvc_file = argv[2];
    }

    printf("========================================\n");
    printf("  Happy Eyeballs v2 Test Program\n");
    printf("========================================\n\n");
    printf("URL: %s\n", url);
    printf("Alt-Svc file: %s\n", altsvc_file);
    printf("\n");

    /* Initialize libcurl */
    curl_global_init(CURL_GLOBAL_DEFAULT);
    curl = curl_easy_init();

    if(!curl) {
        fprintf(stderr, "Error: curl_easy_init() failed\n");
        curl_global_cleanup();
        return 1;
    }

    /* Set the URL */
    curl_easy_setopt(curl, CURLOPT_URL, url);

    /* Enable Alt-Svc with H3 support, read-only mode */
    curl_easy_setopt(curl, CURLOPT_ALTSVC, altsvc_file);
    curl_easy_setopt(curl, CURLOPT_ALTSVC_CTRL, 
                     CURLALTSVC_H3 | CURLALTSVC_READONLYFILE);

    /* Force fresh connection (no connection reuse) */
    curl_easy_setopt(curl, CURLOPT_FRESH_CONNECT, 1L);

    /* Enable verbose output to see Happy Eyeballs in action */
    curl_easy_setopt(curl, CURLOPT_VERBOSE, 1L);

    /* Set debug callback for detailed connection info */
    curl_easy_setopt(curl, CURLOPT_DEBUGFUNCTION, debug_callback);

    /* Set header callback */
    curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_callback);

    /* Set write callback */
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body_size);

    /* Follow redirects */
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);

    /* Set reasonable timeout */
    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);

    /* Disable SSL verification for testing (insecure!) */
    /* In production, use proper CA bundle: curl_easy_setopt(curl, CURLOPT_CAINFO, "path/to/ca-bundle.crt"); */
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);

    printf("========================================\n");
    printf("  Performing Request\n");
    printf("========================================\n\n");

    /* Perform the request */
    res = curl_easy_perform(curl);

    printf("\n========================================\n");
    printf("  Result\n");
    printf("========================================\n\n");

    /* Check for errors */
    if(res != CURLE_OK) {
        fprintf(stderr, "❌ Request failed: %s\n", curl_easy_strerror(res));
    } else {
        printf("✅ Request succeeded\n\n");

        /* Get HTTP version used */
        curl_easy_getinfo(curl, CURLINFO_HTTP_VERSION, &http_version);
        
        /* Get effective URL */
        curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &effective_url);

        printf("Results:\n");
        printf("  - Effective URL: %s\n", effective_url ? effective_url : url);
        printf("  - HTTP Version: ");
        
        switch(http_version) {
        case CURL_HTTP_VERSION_1_0:
            printf("HTTP/1.0\n");
            break;
        case CURL_HTTP_VERSION_1_1:
            printf("HTTP/1.1\n");
            break;
        case CURL_HTTP_VERSION_2_0:
            printf("HTTP/2\n");
            break;
        case CURL_HTTP_VERSION_3:
            printf("HTTP/3\n");
            break;
        default:
            printf("Unknown (%ld)\n", http_version);
            break;
        }
        
        printf("  - Body size: %zu bytes\n", body_size);

        /* Get connection info */
        long response_code = 0;
        double total_time = 0;
        double connect_time = 0;
        
        curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code);
        curl_easy_getinfo(curl, CURLINFO_TOTAL_TIME, &total_time);
        curl_easy_getinfo(curl, CURLINFO_CONNECT_TIME, &connect_time);

        printf("  - Response code: %ld\n", response_code);
        printf("  - Connect time: %.3f seconds\n", connect_time);
        printf("  - Total time: %.3f seconds\n", total_time);

        printf("\n");
        
        /* Check if Happy Eyeballs was triggered */
        if(http_version == CURL_HTTP_VERSION_3) {
            printf("🎯 HTTP/3 (QUIC) connection successful!\n");
        } else if(http_version == CURL_HTTP_VERSION_2_0) {
            printf("🔄 Fallback to HTTP/2 (TCP) - QUIC may be blocked\n");
        } else {
            printf("📡 Using HTTP/1.x\n");
        }
    }

    /* Cleanup */
    curl_easy_cleanup(curl);
    curl_global_cleanup();

    printf("\n========================================\n");
    printf("  Test Complete\n");
    printf("========================================\n");

    return (res == CURLE_OK) ? 0 : 1;
}

alt-svc.txt

# Your alt-svc cache. https://curl.se/docs/alt-svc.html
# This file was generated by libcurl! Edit at your own risk.
h1 dg01xmppapi.zoom.com.cn 443 h3 dg01xmppapi.zoom.com.cn 443 "20251129 07:17:35" 0 0

Actual Behavior

* !!! WARNING !!!
* This is a debug build of libcurl, do not use in production.
* STATE: INIT => SETUP handle 0x131808208; line 2393
* STATE: SETUP => CONNECT handle 0x131808208; line 2409
* check Alt-Svc for host dg01xmppapi.zoom.com.cn
* Alt-svc connecting from [h1]dg01xmppapi.zoom.com.cn:443 to [h3]dg01xmppapi.zoom.com.cn:443
* Added connection 0. The cache now contains 1 members
* STATE: CONNECT => RESOLVING handle 0x131808208; line 2308
* Host dg01xmppapi.zoom.com.cn:443 was resolved.
* IPv6: (none)
* IPv4: 221.122.64.37, 221.122.64.38
* STATE: RESOLVING => CONNECTING handle 0x131808208; line 2266
*  socket type: 2
*   Trying 221.122.64.37:443...
*  socket type: 2
*   Trying 221.122.64.37:443...

Problems:

Both Happy Eyeballs ballers create UDP sockets (type 2 = SOCK_DGRAM)
No TCP fallback socket is created
When QUIC/UDP port 443 is blocked by firewall (common in enterprise networks), both connection attempts fail

Root Cause Analysis

I've traced the issue to two locations in the code:

Problem 1: Global transport set in Alt-Svc handling

In lib/url.c (around line 3164), when Alt-Svc suggests h3:

case ALPN_h3:
  infof(data, "alpn provide h3");
  conn->transport = TRNSPRT_QUIC;  // ← Sets global transport
  data->state.httpwant = CURL_HTTP_VERSION_3;
  break;

This sets conn->transport globally to TRNSPRT_QUIC.

Problem 2: Both ballers inherit the same transport

static CURLcode cf_hc_connect(struct Curl_cfilter *cf, ...) {
  ...
  // Initialize first baller (h3 - HTTP/3)
  cf_hc_baller_init(&ctx->ballers[0], cf, data, cf->conn->transport);
  
  // Initialize second baller (h2 - HTTP/2 fallback)
  if(time_to_start_next(cf, data, 1, now)) {
    cf_hc_baller_init(&ctx->ballers[1], cf, data, cf->conn->transport);
    //                                              ^^^^^^^^^^^^^^^^^^
    //                                              Uses same transport!
  }
}

I expected the following

  • Alt-svc connecting from [h1]example.com:443 to [h3]example.com:443
  • socket type: 2 ← SOCK_DGRAM (UDP/QUIC) - First baller for HTTP/3
  • Trying 1.2.3.4:443...
  • socket type: 1 ← SOCK_STREAM (TCP) - Second baller for HTTP/2 fallback ✅
  • Trying 1.2.3.4:443...

curl/libcurl version

curl 8.12.1

operating system

Darwin Kernel Version 24.6.0

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions