Files
jak-project/third-party/curl/tests/http/test_60_h3_proxy.py
T
Alexander J. Semenuk 5d5e35fb9b fix: Windows toolchain compatibility (curl 8.21 re-vendor, endless reconfigure loop) (#4355)
## Problem

Windows builds break with a current local toolchain (Scoop LLVM 22.1.8,
CMake 4.4.0, VS 2026), in two independent ways:

1. The build stops at curl's deliberate guard: `#error "no non-blocking
method was found/used/set"` in `third-party/curl/lib/nonblock.c`.
2. From the second configure onward, `cmake --build` re-runs CMake in an
endless loop (observed 42 consecutive reconfigure cycles in a single
build). Likely the same mechanism behind the "endlessly building" VS
2026 note in `docs/setup/dev/vs.md`.

## Root cause

1. `third-party/curl/CMake/CurlTests.c` passes `int *` to
`ioctlsocket()`, whose third parameter is `u_long *`. Clang 22 promotes
`-Wincompatible-pointer-types` to a hard error in C, so the
`HAVE_IOCTLSOCKET_FIONBIO` try_compile silently fails and
`curl_config.h` never defines it. Upstream CI does not see this because
the windows-2022 runner image ships an older LLVM. GCC 14 promotes the
same warning to a hard error, which is very likely the `CurlTests.c.obj`
failure reported from MSYS2 in open-goal/jak-project#3551. Upstream curl
hit the identical problem with GCC 14 and fixed the probe in curl 8.8.0
(curl/curl#13578).
2. The root CMakeLists copies the build tree's `compile_commands.json`
into `<src>/build/` for clangd using `configure_file()`, which registers
its input as a configure dependency. CMake rewrites
`compile_commands.json` late in every generation, after
`CTestTestfile.cmake` and `cmake_install.cmake` (outputs of the same
Ninja regen rule), so once the dependency is registered the rule is
deterministically dirty and every `ninja` invocation re-runs CMake. A
pristine first configure is safe (the file does not exist yet, so the
`if(EXISTS ...)` guard skips the copy), which is why the loop looks
machine- or IDE-specific.

## Fix

1. Per review, re-vendor `third-party/curl` at the `curl-8_21_0` tag
(previously `curl-8_3_0`), which carries the upstream probe fix plus two
years of upstream development; `vendor.yaml` updated to match.
Adjustments the version jump forced:
- curl 8.15 removed the native macOS Secure Transport backend
(`CURL_USE_SECTRANSP`), so macOS now builds curl against OpenSSL like
Linux. The two macOS workflows install Homebrew `openssl@3` and export
`OPENSSL_ROOT_DIR` (keg-only), and the macOS setup docs gained the same
two lines.
- `CURL_BROTLI` / `CURL_ZSTD` switched to AUTO-detection in curl 8.10;
pinned OFF to keep the previous no-compression behavior and avoid
silently linking whatever the CI images happen to have.
- curl's new top-level `BUILD_EXAMPLES` cache option (default ON) leaked
into discord-rpc's identically named option and broke configure at a
nonexistent `examples/send-presence` directory; pinned OFF ahead of the
third-party subdirectories.

The diff is dominated by the mechanical tag-tree swap under
`third-party/curl` (linguist-vendored, collapsed in review). The
hand-written changes are `CMakeLists.txt`, the two macOS workflows,
`docs/setup/system/macos.md`, and `vendor.yaml`.
2. Swap `configure_file()` for `file(COPY ...)`: the same clangd copy
with no configure dependency registered. (`file(COPY_FILE ...
ONLY_IF_DIFFERENT)` would be cleaner still but requires CMake 3.21,
above the declared `cmake_minimum_required(VERSION 3.10)`.)

## Test plan

- [x] Fresh `cmake --preset Release-windows-clang` (LLVM 22, no cache
seeding) completes and logs `Enabled SSL backends: Schannel`; the
FIONBIO probe passes without the previous `#error`
- [x] Full Windows Release build from scratch in the branch worktree
(all 1422 targets)
- [x] goalc-test suite: 1509 passed, 0 failed
- [x] Second consecutive configure with `compile_commands.json` present:
the regen rule in `build.ninja` has no `compile_commands.json` input;
`<src>/build/compile_commands.json` is still refreshed for clangd
- [x] Repeated `ninja` invocations after a full build no longer re-run
CMake
- [x] macOS Intel and ARM CI green (first exercise of the OpenSSL
backend switch)

---

I work off a self-hosted forge, so this GitHub account is quiet; the
configure logs and ninja dirty-node traces from the investigation are
available if anyone wants the raw data.

(AI-assisted)
2026-07-27 19:19:18 -04:00

627 lines
23 KiB
Python
Vendored
Generated

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ***************************************************************************
# _ _ ____ _
# Project ___| | | | _ \| |
# / __| | | | |_) | |
# | (__| |_| | _ <| |___
# \___|\___/|_| \_\_____|
#
# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://curl.se/docs/copyright.html.
#
# You may opt to use, copy, modify, merge, publish, distribute and/or sell
# copies of the Software, and permit persons to whom the Software is
# furnished to do so, under the terms of the COPYING file.
#
# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
# KIND, either express or implied.
#
# SPDX-License-Identifier: curl
#
###########################################################################
#
import os
import subprocess
import time
import pytest
from testenv import CurlClient, Env
MARK_NEEDS_HTTPS_PROXY = pytest.mark.skipif(
condition=not Env.curl_has_feature("HTTPS-proxy"),
reason="curl lacks HTTPS-proxy support"
)
MARK_NEEDS_HTTP3 = pytest.mark.skipif(
condition=not Env.curl_has_feature("HTTP3"), reason="curl lacks HTTP/3 support"
)
MARK_NEEDS_PROXY_HTTP3 = pytest.mark.skipif(
condition=not Env.curl_has_feature("proxy-HTTP3"),
reason="curl lacks experimental HTTP/3 proxy support"
)
MARK_NEEDS_NGHTTP3 = pytest.mark.skipif(
condition=not Env.curl_uses_lib("nghttp3"), reason="only supported with nghttp3"
)
MARK_NEEDS_NGHTTP2 = pytest.mark.skipif(
condition=not Env.curl_uses_lib("nghttp2"), reason="only supported with nghttp2"
)
MARK_NEEDS_H2O = pytest.mark.skipif(
condition=not Env.have_h2o(), reason="no h2o available"
)
MARK_NEEDS_NGHTTPX = pytest.mark.skipif(
condition=not Env.have_nghttpx(), reason="no nghttpx available"
)
UNSUPPORTED_OPT_MSG = "does not support this"
H2O_HELLO_MSG = '"message": "Hello from h2o HTTP/3 server"'
def _require_available(**items):
missing = [name for name, value in items.items() if not value]
if missing:
pytest.skip(f"{' or '.join(missing)} not available")
def _download_path(curl: CurlClient) -> str:
return os.path.join(curl.run_dir, "download_#1.data")
def _check_download_message(curl: CurlClient, expected: str):
dpath = _download_path(curl)
assert os.path.exists(dpath), f"Download file not found: {dpath}"
with open(dpath, "r") as fd:
content = fd.read()
assert expected in content, f"Unexpected response content: {content}"
def _check_download_size(curl: CurlClient, expected_size: int):
dpath = _download_path(curl)
assert os.path.exists(dpath), f"Download file not found: {dpath}"
actual = os.path.getsize(dpath)
assert actual == expected_size, f"expected {expected_size}B download, got {actual}B"
def _nghttpx_proxy_args(
env: Env,
nghttpx,
nghttpx_fwd,
proxy_proto: str,
tunnel: bool,
):
port = env.pts_port(proxy_proto)
domain = env.proxy_domain
xxarg = None
if proxy_proto == "h3":
port = nghttpx.port
domain = env.domain1
xxarg = "--proxy-http3"
elif proxy_proto == "h2":
xxarg = "--proxy-http2"
xargs = [
"--proxy", f"https://{domain}:{port}/",
"--resolve", f"{domain}:{port}:127.0.0.1",
"--proxy-cacert", env.ca.cert_file
]
if xxarg:
xargs.append(xxarg)
if tunnel:
xargs.append("--proxytunnel")
return xargs
def _h2o_proxy_args(
env: Env,
h2o_proxy,
proxy_proto: str,
tunnel: bool,
):
pport = env.pts_port(proxy_proto, use_h2o=True)
xargs = [
"--proxy", f"https://{env.proxy_domain}:{pport}/",
"--resolve", f"{env.proxy_domain}:{pport}:127.0.0.1",
"--proxy-cacert", env.ca.cert_file,
"--cacert", env.ca.cert_file,
]
if proxy_proto == "h2":
xargs.append("--proxy-http2")
elif proxy_proto == "h3":
xargs.append("--proxy-http3")
if tunnel:
xargs.append("--proxytunnel")
return xargs
@MARK_NEEDS_HTTPS_PROXY
@MARK_NEEDS_HTTP3
@MARK_NEEDS_NGHTTP3
class TestH3Proxy:
# Success matrix for HTTP/3 proxy CONNECT / CONNECT-UDP.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
@pytest.mark.parametrize(
["alpn_proto", "proxy_proto"],
[
pytest.param("http/1.1", "h3", id="h1_over_h3_proxytunnel"),
pytest.param(
"h2",
"h3",
marks=MARK_NEEDS_NGHTTP2,
id="h2_over_h3_proxytunnel",
),
pytest.param("h3", "h3", id="h3_over_h3_proxytunnel"),
pytest.param(
"h3",
"h2",
marks=MARK_NEEDS_NGHTTP2,
id="h3_over_h2_proxytunnel",
),
pytest.param("h3", "http/1.1", id="h3_over_h1_proxytunnel"),
],
)
def test_60_01_connect_tunnel(
self,
env: Env,
h2o_server,
h2o_proxy,
alpn_proto,
proxy_proto,
):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/data.json"
proxy_args = _h2o_proxy_args(
env, h2o_proxy, proxy_proto, tunnel=True
)
r = curl.http_download(
urls=[url], alpn_proto=alpn_proto, with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
_check_download_message(curl, H2O_HELLO_MSG)
# Failure matrix when proxy side does not support requested mode.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_NGHTTPX
@pytest.mark.parametrize(
["alpn_proto", "proxy_proto", "exp_err"],
[
pytest.param(
"http/1.1",
"h3",
"could not connect to server",
id="fail_h1_over_h3_proxytunnel",
),
pytest.param(
"h2",
"h3",
"could not connect to server",
marks=MARK_NEEDS_NGHTTP2,
id="fail_h2_over_h3_proxytunnel",
),
pytest.param(
"h3",
"h3",
"could not connect to server",
id="fail_h3_over_h3_proxytunnel",
),
#pytest.param(
# "h3",
# "h2",
# "proxy closed connection",
# marks=MARK_NEEDS_NGHTTP2,
# id="fail_h3_over_h2_proxytunnel",
#),
pytest.param(
"h3",
"http/1.1",
"connect-udp tunnel failed",
id="fail_h3_over_h1_proxytunnel",
),
],
)
def test_60_02_connect_tunnel_fail(
self,
env: Env,
httpd,
nghttpx,
nghttpx_fwd,
h2o_proxy,
alpn_proto,
proxy_proto,
exp_err,
):
_require_available(httpd=httpd, nghttpx=nghttpx, nghttpx_fwd=nghttpx_fwd,
h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
url = f"https://localhost:{env.https_port}/data.json"
proxy_args = _nghttpx_proxy_args(
env, nghttpx, nghttpx_fwd, proxy_proto, tunnel=True
)
r = curl.http_download(
urls=[url], alpn_proto=alpn_proto, with_stats=True, extra_args=proxy_args
)
assert r.exit_code != 0, f"Expected failure but curl succeeded: {r.dump_logs()}"
assert exp_err in r.stderr.lower(), (
f"Expected protocol/proxy error but got: {r.dump_logs()}"
)
# Behavior checks for tunnel vs non-tunnel proxy mode selection.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_NGHTTPX
@pytest.mark.parametrize(
["proxy_proto"],
[
#pytest.param("h3", id="proxy_h3"),
pytest.param("h2", marks=MARK_NEEDS_NGHTTP2, id="proxy_h2"),
pytest.param("http/1.1", id="proxy_h1"),
],
)
def test_60_03_h3_target_auto_connect_udp(
self, env: Env, httpd, nghttpx, nghttpx_fwd, proxy_proto
):
_require_available(
httpd=httpd, nghttpx=nghttpx, nghttpx_fwd=nghttpx_fwd
)
curl = CurlClient(env=env)
url = f"https://localhost:{httpd.ports['https']}/data.json"
proxy_args = _nghttpx_proxy_args(
env, nghttpx, nghttpx_fwd, proxy_proto, tunnel=False
)
r = curl.http_download(
urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
)
# An HTTP/3 target auto-triggers CONNECT-UDP even without --proxytunnel,
# as HTTPS targets auto-trigger CONNECT. nghttpx does not support
# CONNECT-UDP so this fails, which confirms auto-CONNECT-UDP is active.
assert r.exit_code != 0, (
"expected failure: h3 target auto-triggers CONNECT-UDP "
"which nghttpx does not support"
)
assert "connect-udp" in r.stderr.lower(), (
f"expected CONNECT-UDP attempt in output, got: {r.dump_logs()}"
)
# Guard checks for unsupported HTTP/3 proxy options.
@pytest.mark.skipif(
condition=Env.curl_has_feature("proxy-HTTP3"), reason="curl has h3 proxy support"
)
def test_60_04_guard_proxy_http3_unsupported(self, env: Env, httpd):
curl = CurlClient(env=env)
url = f"https://localhost:{httpd.ports['https']}/data.json"
proxy_args = [
"--proxy",
"https://127.0.0.1:1/",
"--proxy-http3",
"--proxytunnel",
"--cacert",
env.ca.cert_file,
]
r = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_exit_code(2)
assert UNSUPPORTED_OPT_MSG in r.stderr.lower(), (
f"Expected unsupported option failure but got: {r.stderr}"
)
# Robustness checks for shutdown and proxy loss during transfer.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_05_graceful_shutdown(self, env: Env, h2o_server, h2o_proxy):
if not env.curl_is_debug():
pytest.skip("needs debug curl for shutdown trace lines")
if not env.curl_is_verbose():
pytest.skip("needs verbose-strings curl build")
curl = CurlClient(env=env, run_env={"CURL_DEBUG": "all"})
url = f"https://localhost:{h2o_server.port}/data.json"
proxy_args = curl.get_proxy_args(proto="h3", tunnel=True)
proxy_args.extend(["--cacert", env.ca.cert_file, "--insecure"])
r = curl.http_download(
urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
shutdown_lines = [
line
for line in r.trace_lines
if ("start shutdown(" in line.lower())
or ("shutdown completely sent off" in line.lower())
]
assert shutdown_lines, f"No shutdown trace lines found:\n{r.stderr}"
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_06_proxy_drop_mid_transfer(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
env.make_data_file(indir=h2o_server.docs_dir, fname="proxy-drop-20m", fsize=20 * 1024 * 1024)
proxy_port = h2o_proxy.port
url = f"https://localhost:{h2o_server.port}/proxy-drop-20m"
out_path = os.path.join(env.gen_dir, "proxy-drop.out")
if os.path.exists(out_path):
os.remove(out_path)
args = [
env.curl,
"--http1.1",
"--proxy", f"https://{env.proxy_domain}:{proxy_port}/",
"--resolve", f"{env.proxy_domain}:{proxy_port}:127.0.0.1",
"--proxy-http3",
"--proxytunnel",
"--proxy-cacert", env.ca.cert_file,
"--cacert", env.ca.cert_file,
"--limit-rate", "10k",
"--max-time", "20",
"-o", out_path,
"-v",
url,
]
proc = None
try:
proc = subprocess.Popen(
args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
while not os.path.exists(out_path):
time.sleep(0.1)
assert h2o_proxy.kill(), "failed to stop h2o proxy"
_, stderr = proc.communicate(timeout=30)
assert proc.returncode != 0, (
"curl should fail when proxy is terminated mid-transfer"
)
assert proc.returncode == 56, f'{stderr}'
finally:
if proc and (proc.poll() is None):
proc.kill()
proc.wait(timeout=5)
assert h2o_proxy.start(), "failed to restart h2o proxy"
# Large file transfers and multiplexing through HTTP/3 proxy.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_07_large_download(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
env.make_data_file(indir=h2o_server.docs_dir, fname="download-10m", fsize=10 * 1024 * 1024)
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/download-10m"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
r = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
_check_download_size(curl, 10 * 1024 * 1024)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_08_large_upload(self, env: Env, httpd, h2o_server, h2o_proxy):
_require_available(h2o_proxy=h2o_proxy)
env.make_data_file(indir=env.gen_dir, fname="upload-2m", fsize=2 * 1024 * 1024)
fdata = os.path.join(env.gen_dir, "upload-2m")
curl = CurlClient(env=env)
url = f"https://localhost:{httpd.ports['https']}/curltest/echo?id=[0-0]"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
r = curl.http_upload(
urls=[url],
data=f"@{fdata}",
alpn_proto="http/1.1",
with_stats=True,
extra_args=proxy_args,
)
r.check_response(count=1, http_status=200)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_09_parallel_downloads(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
env.make_data_file(indir=h2o_server.docs_dir, fname="download-1m", fsize=1 * 1024 * 1024)
count = 5
curl = CurlClient(env=env)
urln = f"https://localhost:{h2o_server.port}/download-1m?[0-{count - 1}]"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
proxy_args.extend(["--parallel", "--parallel-max", f"{count}"])
r = curl.http_download(
urls=[urln], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_response(count=count, http_status=200)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_10_proxy_basic_auth(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/data.json"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
proxy_args.extend(["--proxy-user", "testuser:testpass"])
r = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
_check_download_message(curl, H2O_HELLO_MSG)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_11_connection_reuse(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
urln = f"https://localhost:{h2o_server.port}/data.json?[0-2]"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
r = curl.http_download(
urls=[urln], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_response(count=3, http_status=200)
assert r.total_connects <= 3, (
f"expected proxy connection reuse, got {r.total_connects} connects"
)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
@pytest.mark.skipif(condition=not Env.curl_has_feature('SSLS-EXPORT'),
reason='curl lacks SSL session export support')
def test_60_12_quic_session_resumption(self, env: Env, h2o_server, h2o_proxy):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/data.json"
xargs = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
session_file = os.path.join(env.gen_dir, 'test_60_12.sessions')
if os.path.exists(session_file):
os.remove(session_file)
xargs.extend(['--ssl-sessions', session_file])
# First request establishes QUIC session
r1 = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=xargs
)
r1.check_response(count=1, http_status=200)
xargs.extend(['--trace-config', 'ssls'])
r2 = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=xargs
)
r2.check_response(count=1, http_status=200)
reuses = [line for line in r2.trace_lines if '[SSLS] took session for proxy.http.curl.se' in line]
assert len(reuses), f'{r2.dump_logs()}'
# CONNECT-UDP tunnel payload size and capsule-protocol tests.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
@pytest.mark.parametrize(
"fname,fsize",
[
("download-1400", 1400),
("download-1m", 1 * 1024 * 1024),
("download-10m", 10 * 1024 * 1024),
],
)
def test_60_13_udp_tunnel_payload_sizes(
self, env: Env, h2o_server, h2o_proxy, fname, fsize
):
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
env.make_data_file(indir=h2o_server.docs_dir, fname=fname, fsize=fsize)
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/{fname}"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
r = curl.http_download(
urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
_check_download_size(curl, fsize)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_NGHTTPX
def test_60_14_udp_tunnel_capsule_absent(
self, env: Env, httpd, nghttpx, nghttpx_fwd
):
_require_available(
httpd=httpd, nghttpx=nghttpx, nghttpx_fwd=nghttpx_fwd
)
curl = CurlClient(env=env)
url = f"https://localhost:{httpd.ports['https']}/data.json"
proxy_args = _nghttpx_proxy_args(
env, nghttpx, nghttpx_fwd, "h3", tunnel=True
)
r = curl.http_download(
urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
)
assert r.exit_code != 0, (
"expected failure: nghttpx does not support CONNECT-UDP / Capsule-Protocol"
)
# Timeout and protocol-mismatch edge cases.
#@MARK_NEEDS_PROXY_HTTP3
#@MARK_NEEDS_H2O
#def test_60_15_connect_timeout(self, env: Env, h2o_proxy):
# _require_available(h2o_proxy=h2o_proxy)
# curl = CurlClient(env=env, timeout=15)
# url = f"https://localhost:{h2o_proxy.port}/data.json"
# # ipv6 0100::/64 is supposed to go into the void (rfc6666)
# xargs = [
# '--proxy', 'https://xxx.invalid/',
# '--resolve', 'xxx.invalid:443:0100::1,0100::2,0100::3',
# '--proxy-http3', '--proxytunnel',
# '--connect-timeout', '1',
# ]
# r = curl.http_download(
# urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=xargs
# )
# r.check_exit_code(28) # CURLE_OPERATION_TIMEDOUT
# assert r.duration.total_seconds() < 10, (
# f"timeout not respected: took {r.duration.total_seconds():.1f}s"
# )
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
@MARK_NEEDS_NGHTTP2
def test_60_16_h2_uses_connect_tcp_not_udp(self, env: Env, httpd, h2o_proxy):
_require_available(httpd=httpd, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env)
url = f"https://localhost:{env.https_port}/data.json"
proxy_args = curl.get_proxy_args("h3", tunnel=True)
# h2 inner traffic always uses CONNECT (TCP), never CONNECT-UDP,
# even through an HTTP/3 proxy with --proxytunnel. h2o supports
# CONNECT TCP tunneling, so this request succeeds.
r = curl.http_download(
urls=[url], alpn_proto="h2", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
# Verify that happy eyeballs is active for HTTP/3 proxy connections.
#
# With the H3-PROXY filter sitting above HAPPY-EYEBALLS -> UDP, address
# family selection to the proxy is done by happy eyeballs.
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
def test_60_17_happy_eyeballs_filter_present(self, env: Env, h2o_server, h2o_proxy):
"""Verbose trace confirms HAPPY-EYEBALLS filter is in the H3 proxy chain."""
if not env.curl_is_debug():
pytest.skip("needs debug curl for filter trace")
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
curl = CurlClient(env=env, run_env={"CURL_DEBUG": "HAPPY-EYEBALLS,H3-PROXY"})
url = f"https://localhost:{h2o_server.port}/data.json"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
r = curl.http_download(
urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
)
r.check_response(count=1, http_status=200)
assert "happy-eyeballs" in r.stderr.lower(), (
f"expected HAPPY-EYEBALLS trace for H3 proxy, got: {r.stderr}"
)
@MARK_NEEDS_PROXY_HTTP3
@MARK_NEEDS_H2O
@MARK_NEEDS_NGHTTP2
def test_60_18_happy_eyeballs_ipv4_all_proto(self, env: Env, h2o_server, h2o_proxy):
"""IPv4-forced H3 proxy works for h1/h2/h3 inner protocols."""
_require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
for alpn_proto in ["http/1.1", "h2", "h3"]:
curl = CurlClient(env=env)
url = f"https://localhost:{h2o_server.port}/data.json"
proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True)
proxy_args.append("--ipv4")
r = curl.http_download(
urls=[url],
alpn_proto=alpn_proto,
with_stats=True,
extra_args=proxy_args,
)
r.check_response(count=1, http_status=200)