connections: introduce http/3 happy eyeballs

New cfilter HTTP-CONNECT for h3/h2/http1.1 eyeballing.
- filter is installed when `--http3` in the tool is used (or
  the equivalent CURLOPT_ done in the library)
- starts a QUIC/HTTP/3 connect right away. Should that not
  succeed after 100ms (subject to change), a parallel attempt
  is started for HTTP/2 and HTTP/1.1 via TCP
- both attempts are subject to IPv6/IPv4 eyeballing, same
  as happens for other connections
- tie timeout to the ip-version HAPPY_EYEBALLS_TIMEOUT
- use a `soft` timeout at half the value. When the soft timeout
  expires, the HTTPS-CONNECT filter checks if the QUIC filter
  has received any data from the server. If not, it will start
  the HTTP/2 attempt.

HTTP/3(ngtcp2) improvements.
- setting call_data in all cfilter calls similar to http/2 and vtls filters
  for use in callback where no stream data is available.
- returning CURLE_PARTIAL_FILE for prematurely terminated transfers
- enabling pytest test_05 for h3
- shifting functionality to "connect" UDP sockets from ngtcp2
  implementation into the udp socket cfilter. Because unconnected
  UDP sockets are weird. For example they error when adding to a
  pollset.

HTTP/3(quiche) improvements.
- fixed upload bug in quiche implementation, now passes 251 and pytest
- error codes on stream RESET
- improved debug logs
- handling of DRAIN during connect
- limiting pending event queue

HTTP/2 cfilter improvements.
- use LOG_CF macros for dynamic logging in debug build
- fix CURLcode on RST streams to be CURLE_PARTIAL_FILE
- enable pytest test_05 for h2
- fix upload pytests and improve parallel transfer performance.

GOAWAY handling for ngtcp2/quiche
- during connect, when the remote server refuses to accept new connections
  and closes immediately (so the local conn goes into DRAIN phase), the
  connection is torn down and a another attempt is made after a short grace
  period.
  This is the behaviour observed with nghttpx when we tell it to  shut
  down gracefully. Tested in pytest test_03_02.

TLS improvements
- ALPN selection for SSL/SSL-PROXY filters in one vtls set of functions, replaces
  copy of logic in all tls backends.
- standardized the infof logging of offered ALPNs
- ALPN negotiated: have common function for all backends that sets alpn proprty
  and connection related things based on the negotiated protocol (or lack thereof).

- new tests/tests-httpd/scorecard.py for testing h3/h2 protocol implementation.
  Invoke:
    python3 tests/tests-httpd/scorecard.py --help
  for usage.

Improvements on gathering connect statistics and socket access.
- new CF_CTRL_CONN_REPORT_STATS cfilter control for having cfilters
  report connection statistics. This is triggered when the connection
  has completely connected.
- new void Curl_pgrsTimeWas(..) method to report a timer update with
  a timestamp of when it happend. This allows for updating timers
  "later", e.g. a connect statistic after full connectivity has been
  reached.
- in case of HTTP eyeballing, the previous changes will update
  statistics only from the filter chain that "won" the eyeballing.
- new cfilter query CF_QUERY_SOCKET for retrieving the socket used
  by a filter chain.
  Added methods Curl_conn_cf_get_socket() and Curl_conn_get_socket()
  for convenient use of this query.
- Change VTLS backend to query their sub-filters for the socket when
  checks during the handshake are made.

HTTP/3 documentation on how https eyeballing works.

TLS improvements
- ALPN selection for SSL/SSL-PROXY filters in one vtls set of functions, replaces
  copy of logic in all tls backends.
- standardized the infof logging of offered ALPNs
- ALPN negotiated: have common function for all backends that sets alpn proprty
  and connection related things based on the negotiated protocol (or lack thereof).

Scorecard with Caddy.
- configure can be run with `--with-test-caddy=path` to specify which caddy to use for testing
- tests/tests-httpd/scorecard.py now measures download speeds with caddy

pytest improvements
- adding Makfile to clean gen dir
- adding nghttpx rundir creation on start
- checking httpd version 2.4.55 for test_05 cases where it is needed. Skipping with message if too old.
- catch exception when checking for caddy existance on system.

Closes #10349
This commit is contained in:
Stefan Eissing 2023-02-01 17:13:12 +01:00 committed by Daniel Stenberg
parent b7aaf074e5
commit 671158242d
No known key found for this signature in database
GPG key ID: 5CC908FDB71E12C2
61 changed files with 3599 additions and 1249 deletions

View file

@ -0,0 +1,27 @@
#***************************************************************************
# _ _ ____ _
# 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
#
###########################################################################
clean-local:
rm -rf *.pyc __pycache__
rm -rf gen

View file

@ -33,7 +33,11 @@ apachectl = @APACHECTL@
[test]
http_port = 5001
https_port = 5002
h3_port = 5003
h3_port = 5002
[nghttpx]
nghttpx = @HTTPD_NGHTTPX@
nghttpx = @HTTPD_NGHTTPX@
[caddy]
caddy = @CADDY@
port = 5004

View file

@ -70,7 +70,7 @@ def httpd(env) -> Httpd:
@pytest.fixture(scope='package')
def nghttpx(env) -> Optional[Nghttpx]:
def nghttpx(env, httpd) -> Optional[Nghttpx]:
if env.have_h3_server():
nghttpx = Nghttpx(env=env)
nghttpx.clear_logs()

View file

@ -0,0 +1,400 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#***************************************************************************
# _ _ ____ _
# Project ___| | | | _ \| |
# / __| | | | |_) | |
# | (__| |_| | _ <| |___
# \___|\___/|_| \_\_____|
#
# Copyright (C) 2008 - 2022, 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 argparse
import json
import logging
import os
import sys
from datetime import datetime
from statistics import mean
from typing import Dict, Any
from testenv import Env, Httpd, Nghttpx, CurlClient, Caddy, ExecResult
log = logging.getLogger(__name__)
class ScoreCardException(Exception):
pass
class ScoreCard:
def __init__(self):
self.verbose = 0
self.env = None
self.httpd = None
self.nghttpx = None
self.caddy = None
def info(self, msg):
if self.verbose > 0:
sys.stderr.write(msg)
sys.stderr.flush()
def handshakes(self, proto: str) -> Dict[str, Any]:
props = {}
sample_size = 10
self.info(f'handshaking ')
for authority in [
f'{self.env.authority_for(self.env.domain1, proto)}'
]:
self.info('localhost')
c_samples = []
hs_samples = []
errors = []
for i in range(sample_size):
self.info('.')
curl = CurlClient(env=self.env)
url = f'https://{authority}/'
r = curl.http_download(urls=[url], alpn_proto=proto)
if r.exit_code == 0 and len(r.stats) == 1:
c_samples.append(r.stats[0]['time_connect'])
hs_samples.append(r.stats[0]['time_appconnect'])
else:
errors.append(f'exit={r.exit_code}')
props['localhost'] = {
'connect': mean(c_samples),
'handshake': mean(hs_samples),
'errors': errors
}
for authority in [
'curl.se', 'google.com', 'cloudflare.com', 'nghttp2.org',
]:
for ipv in ['ipv4', 'ipv6']:
self.info(f'{authority}-{ipv}')
c_samples = []
hs_samples = []
errors = []
for i in range(sample_size):
self.info('.')
curl = CurlClient(env=self.env)
args = [
'--http3-only' if proto == 'h3' else '--http2',
f'--{ipv}', f'https://{authority}/'
]
r = curl.run_direct(args=args, with_stats=True)
if r.exit_code == 0 and len(r.stats) == 1:
c_samples.append(r.stats[0]['time_connect'])
hs_samples.append(r.stats[0]['time_appconnect'])
else:
errors.append(f'exit={r.exit_code}')
props[f'{authority}-{ipv}'] = {
'connect': mean(c_samples) if len(c_samples) else -1,
'handshake': mean(hs_samples) if len(hs_samples) else -1,
'errors': errors
}
self.info('\n')
return props
def _make_docs_file(self, docs_dir: str, fname: str, fsize: int):
fpath = os.path.join(docs_dir, fname)
data1k = 1024*'x'
flen = 0
with open(fpath, 'w') as fd:
while flen < fsize:
fd.write(data1k)
flen += len(data1k)
return flen
def _check_downloads(self, r: ExecResult, count: int):
error = ''
if r.exit_code != 0:
error += f'exit={r.exit_code} '
if r.exit_code != 0 or len(r.stats) != count:
error += f'stats={len(r.stats)}/{count} '
fails = [s for s in r.stats if s['response_code'] != 200]
if len(fails) > 0:
error += f'{len(fails)} failed'
return error if len(error) > 0 else None
def transfer_single(self, url: str, proto: str, count: int):
sample_size = count
count = 1
samples = []
errors = []
self.info(f'{sample_size}x single')
for i in range(sample_size):
curl = CurlClient(env=self.env)
r = curl.http_download(urls=[url], alpn_proto=proto)
err = self._check_downloads(r, count)
if err:
errors.append(err)
else:
samples.append(r.stats[0]['speed_download'])
self.info(f'.')
return {
'count': count,
'samples': sample_size,
'speed': mean(samples) if len(samples) else -1,
'errors': errors
}
def transfer_serial(self, url: str, proto: str, count: int):
sample_size = 1
samples = []
errors = []
url = f'{url}?[0-{count - 1}]'
self.info(f'{sample_size}x{count} serial')
for i in range(sample_size):
curl = CurlClient(env=self.env)
r = curl.http_download(urls=[url], alpn_proto=proto)
self.info(f'.')
err = self._check_downloads(r, count)
if err:
errors.append(err)
else:
for s in r.stats:
samples.append(s['speed_download'])
return {
'count': count,
'samples': sample_size,
'speed': mean(samples) if len(samples) else -1,
'errors': errors
}
def transfer_parallel(self, url: str, proto: str, count: int):
sample_size = 1
samples = []
errors = []
url = f'{url}?[0-{count - 1}]'
self.info(f'{sample_size}x{count} parallel')
for i in range(sample_size):
curl = CurlClient(env=self.env)
start = datetime.now()
r = curl.http_download(urls=[url], alpn_proto=proto,
extra_args=['--parallel'])
err = self._check_downloads(r, count)
if err:
errors.append(err)
else:
duration = datetime.now() - start
total_size = sum([s['size_download'] for s in r.stats])
samples.append(total_size / duration.total_seconds())
return {
'count': count,
'samples': sample_size,
'speed': mean(samples) if len(samples) else -1,
'errors': errors
}
def download_url(self, url: str, proto: str, count: int):
self.info(f' {url}: ')
props = {
'single': self.transfer_single(url=url, proto=proto, count=10),
'serial': self.transfer_serial(url=url, proto=proto, count=count),
'parallel': self.transfer_parallel(url=url, proto=proto, count=count),
}
self.info(f'\n')
return props
def downloads(self, proto: str) -> Dict[str, Any]:
scores = {}
if proto == 'h3':
port = self.env.h3_port
via = 'nghttpx'
descr = f'port {port}, proxying httpd'
else:
port = self.env.https_port
via = 'httpd'
descr = f'port {port}'
self.info('httpd downloads\n')
self._make_docs_file(docs_dir=self.httpd.docs_dir, fname='score1.data', fsize=1024*1024)
url1 = f'https://{self.env.domain1}:{port}/score1.data'
self._make_docs_file(docs_dir=self.httpd.docs_dir, fname='score10.data', fsize=10*1024*1024)
url10 = f'https://{self.env.domain1}:{port}/score10.data'
self._make_docs_file(docs_dir=self.httpd.docs_dir, fname='score100.data', fsize=100*1024*1024)
url100 = f'https://{self.env.domain1}:{port}/score100.data'
scores[via] = {
'description': descr,
'1MB-local': self.download_url(url=url1, proto=proto, count=50),
'10MB-local': self.download_url(url=url10, proto=proto, count=50),
'100MB-local': self.download_url(url=url100, proto=proto, count=50),
}
if self.caddy:
port = self.env.caddy_port
via = 'caddy'
descr = f'port {port}'
self.info('caddy downloads\n')
self._make_docs_file(docs_dir=self.caddy.docs_dir, fname='score1.data', fsize=1024 * 1024)
url1 = f'https://{self.env.domain1}:{port}/score1.data'
self._make_docs_file(docs_dir=self.caddy.docs_dir, fname='score10.data', fsize=10 * 1024 * 1024)
url10 = f'https://{self.env.domain1}:{port}/score10.data'
self._make_docs_file(docs_dir=self.caddy.docs_dir, fname='score100.data', fsize=100 * 1024 * 1024)
url100 = f'https://{self.env.domain1}:{port}/score100.data'
scores[via] = {
'description': descr,
'1MB-local': self.download_url(url=url1, proto=proto, count=50),
'10MB-local': self.download_url(url=url10, proto=proto, count=50),
'100MB-local': self.download_url(url=url100, proto=proto, count=50),
}
return scores
def score_proto(self, proto: str, handshakes: bool = True, downloads: bool = True):
self.info(f"scoring {proto}\n")
p = {}
if proto == 'h3':
p['name'] = 'h3'
if not self.env.have_h3_curl():
raise ScoreCardException('curl does not support HTTP/3')
for lib in ['ngtcp2', 'quiche', 'msh3']:
if self.env.curl_uses_lib(lib):
p['implementation'] = lib
break
elif proto == 'h2':
p['name'] = 'h2'
if not self.env.have_h2_curl():
raise ScoreCardException('curl does not support HTTP/2')
for lib in ['nghttp2', 'hyper']:
if self.env.curl_uses_lib(lib):
p['implementation'] = lib
break
else:
raise ScoreCardException(f"unknown protocol: {proto}")
if 'implementation' not in p:
raise ScoreCardException(f'did not recognized {p} lib')
p['version'] = Env.curl_lib_version(p['implementation'])
score = {
'curl': self.env.curl_version(),
'os': self.env.curl_os(),
'protocol': p,
}
if handshakes:
score['handshakes'] = self.handshakes(proto=proto)
if downloads:
score['downloads'] = self.downloads(proto=proto)
self.info("\n")
return score
def fmt_ms(self, tval):
return f'{int(tval*1000)} ms' if tval >= 0 else '--'
def fmt_mb(self, val):
return f'{val/(1024*1024):0.000f} MB' if val >= 0 else '--'
def fmt_mbs(self, val):
return f'{val/(1024*1024):0.000f} MB/s' if val >= 0 else '--'
def print_score(self, score):
print(f'{score["protocol"]["name"].upper()} in curl {score["curl"]} ({score["os"]}) via '
f'{score["protocol"]["implementation"]}/{score["protocol"]["version"]} ')
if 'handshakes' in score:
print('Handshakes')
print(f' {"Host":<25} {"Connect":>12} {"Handshake":>12} {"Errors":<20}')
for key, val in score["handshakes"].items():
print(f' {key:<25} {self.fmt_ms(val["connect"]):>12} '''
f'{self.fmt_ms(val["handshake"]):>12} {"/".join(val["errors"]):<20}')
if 'downloads' in score:
print('Downloads')
for dkey, dval in score["downloads"].items():
print(f' {dkey}: {dval["description"]}')
for skey, sval in dval.items():
if isinstance(sval, str):
continue
print(f' {skey:<13} {"Samples":>10} {"Count":>10} {"Speed":>17} {"Errors":<20}')
for key, val in sval.items():
print(f' {key:<11} {val["samples"]:>10} '''
f'{val["count"]:>10} {self.fmt_mbs(val["speed"]):>17} '
f'{"/".join(val["errors"]):<20}')
def main(self):
parser = argparse.ArgumentParser(prog='scorecard', description="""
Run a range of tests to give a scorecard for a HTTP protocol
'h3' or 'h2' implementation in curl.
""")
parser.add_argument("-v", "--verbose", action='count', default=0,
help="log more output on stderr")
parser.add_argument("-t", "--text", action='store_true', default=False,
help="print text instead of json")
parser.add_argument("-d", "--downloads", action='store_true', default=False,
help="evaluate downloads only")
parser.add_argument("protocols", nargs='*', help="Name(s) of protocol to score")
args = parser.parse_args()
self.verbose = args.verbose
if args.verbose > 0:
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
logging.getLogger('').addHandler(console)
protocols = args.protocols if len(args.protocols) else ['h2', 'h3']
handshakes = True
downloads = True
if args.downloads:
handshakes = False
rv = 0
self.env = Env()
self.env.setup()
self.httpd = None
self.nghttpx = None
self.caddy = None
try:
self.httpd = Httpd(env=self.env)
assert self.httpd.exists(), f'httpd not found: {self.env.httpd}'
self.httpd.clear_logs()
assert self.httpd.start()
if 'h3' in protocols:
self.nghttpx = Nghttpx(env=self.env)
self.nghttpx.clear_logs()
assert self.nghttpx.start()
if self.env.caddy:
self.caddy = Caddy(env=self.env)
self.caddy.clear_logs()
assert self.caddy.start()
for p in protocols:
score = self.score_proto(proto=p, handshakes=handshakes, downloads=downloads)
if args.text:
self.print_score(score)
else:
print(json.JSONEncoder(indent=2).encode(score))
except ScoreCardException as ex:
sys.stderr.write(f"ERROR: {str(ex)}\n")
rv = 1
except KeyboardInterrupt:
log.warning("aborted")
rv = 1
finally:
if self.caddy:
self.caddy.stop()
self.caddy = None
if self.nghttpx:
self.nghttpx.stop(wait_dead=False)
if self.httpd:
self.httpd.stop()
self.httpd = None
sys.exit(rv)
if __name__ == "__main__":
ScoreCard().main()

View file

@ -38,6 +38,11 @@ log = logging.getLogger(__name__)
reason=f"missing: {Env.incomplete_reason()}")
class TestBasic:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
# simple http: GET
def test_01_01_http_get(self, env: Env, httpd):
curl = CurlClient(env=env)

View file

@ -24,12 +24,11 @@
#
###########################################################################
#
import json
import logging
from typing import Optional
import os
import pytest
from testenv import Env, CurlClient, ExecResult
from testenv import Env, CurlClient
log = logging.getLogger(__name__)
@ -39,6 +38,18 @@ log = logging.getLogger(__name__)
reason=f"missing: {Env.incomplete_reason()}")
class TestDownload:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, httpd, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
fpath = os.path.join(httpd.docs_dir, 'data-1mb.data')
data1k = 1024*'x'
with open(fpath, 'w') as fd:
fsize = 0
while fsize < 1024*1024:
fd.write(data1k)
fsize += len(data1k)
# download 1 file
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_02_01_download_1(self, env: Env, httpd, nghttpx, repeat, proto):
@ -48,7 +59,7 @@ class TestDownload:
url = f'https://{env.authority_for(env.domain1, proto)}/data.json'
r = curl.http_download(urls=[url], alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_responses(count=1, exp_status=200)
r.check_stats(count=1, exp_status=200)
# download 2 files
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
@ -59,7 +70,7 @@ class TestDownload:
url = f'https://{env.authority_for(env.domain1, proto)}/data.json?[0-1]'
r = curl.http_download(urls=[url], alpn_proto=proto)
assert r.exit_code == 0
r.check_responses(count=2, exp_status=200)
r.check_stats(count=2, exp_status=200)
# download 100 files sequentially
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
@ -71,8 +82,7 @@ class TestDownload:
urln = f'https://{env.authority_for(env.domain1, proto)}/data.json?[0-99]'
r = curl.http_download(urls=[urln], alpn_proto=proto)
assert r.exit_code == 0
r.check_responses(count=100, exp_status=200)
assert len(r.stats) == 100, f'{r.stats}'
r.check_stats(count=100, exp_status=200)
# http/1.1 sequential transfers will open 1 connection
assert r.total_connects == 1
@ -87,7 +97,7 @@ class TestDownload:
r = curl.http_download(urls=[urln], alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0
r.check_responses(count=100, exp_status=200)
r.check_stats(count=100, exp_status=200)
if proto == 'http/1.1':
# http/1.1 parallel transfers will open multiple connections
assert r.total_connects > 1
@ -105,7 +115,7 @@ class TestDownload:
urln = f'https://{env.authority_for(env.domain1, proto)}/data.json?[0-499]'
r = curl.http_download(urls=[urln], alpn_proto=proto)
assert r.exit_code == 0
r.check_responses(count=500, exp_status=200)
r.check_stats(count=500, exp_status=200)
if proto == 'http/1.1':
# http/1.1 parallel transfers will open multiple connections
assert r.total_connects > 1
@ -124,7 +134,7 @@ class TestDownload:
r = curl.http_download(urls=[urln], alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0
r.check_responses(count=500, exp_status=200)
r.check_stats(count=500, exp_status=200)
if proto == 'http/1.1':
# http/1.1 parallel transfers will open multiple connections
assert r.total_connects > 1
@ -146,28 +156,28 @@ class TestDownload:
'--parallel', '--parallel-max', '200'
])
assert r.exit_code == 0, f'{r}'
r.check_responses(count=500, exp_status=200)
r.check_stats(count=500, exp_status=200)
# http2 should now use 2 connections, at most 5
assert r.total_connects <= 5, "h2 should use fewer connections here"
def check_response(self, r: ExecResult, count: int,
exp_status: Optional[int] = None):
if len(r.responses) != count:
seen_queries = []
for idx, resp in enumerate(r.responses):
assert resp['status'] == 200, f'response #{idx} status: {resp["status"]}'
if 'rquery' not in resp['header']:
log.error(f'response #{idx} missing "rquery": {resp["header"]}')
seen_queries.append(int(resp['header']['rquery']))
for i in range(0,count-1):
if i not in seen_queries:
log.error(f'response for query {i} missing')
if r.with_stats and len(r.stats) == count:
log.error(f'got all {count} stats, though')
assert len(r.responses) == count
if exp_status is not None:
for idx, x in enumerate(r.responses):
assert x['status'] == exp_status, \
f'response #{idx} unexpectedstatus: {x["status"]}'
if r.with_stats:
assert len(r.stats) == count, f'{r}'
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_02_08_1MB_serial(self, env: Env,
httpd, nghttpx, repeat, proto):
count = 2
urln = f'https://{env.authority_for(env.domain1, proto)}/data-1mb.data?[0-{count-1}]'
curl = CurlClient(env=env)
r = curl.http_download(urls=[urln], alpn_proto=proto)
assert r.exit_code == 0
r.check_stats(count=count, exp_status=200)
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_02_09_1MB_parallel(self, env: Env,
httpd, nghttpx, repeat, proto):
count = 2
urln = f'https://{env.authority_for(env.domain1, proto)}/data-1mb.data?[0-{count-1}]'
curl = CurlClient(env=env)
r = curl.http_download(urls=[urln], alpn_proto=proto, extra_args=[
'--parallel'
])
assert r.exit_code == 0
r.check_stats(count=count, exp_status=200)

View file

@ -24,12 +24,10 @@
#
###########################################################################
#
import json
import logging
import time
from datetime import timedelta
from threading import Thread
from typing import Optional
import pytest
from testenv import Env, CurlClient, ExecResult
@ -42,6 +40,11 @@ log = logging.getLogger(__name__)
reason=f"missing: {Env.incomplete_reason()}")
class TestGoAway:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
# download files sequentially with delay, reload server for GOAWAY
def test_03_01_h2_goaway(self, env: Env, httpd, nghttpx, repeat):
proto = 'h2'
@ -64,8 +67,7 @@ class TestGoAway:
t.join()
r: ExecResult = self.r
assert r.exit_code == 0, f'{r}'
r.check_responses(count=count, exp_status=200)
assert len(r.stats) == count, f'{r.stats}'
r.check_stats(count=count, exp_status=200)
# reload will shut down the connection gracefully with GOAWAY
# we expect to see a second connection opened afterwards
assert r.total_connects == 2
@ -77,7 +79,6 @@ class TestGoAway:
# download files sequentially with delay, reload server for GOAWAY
@pytest.mark.skipif(condition=not Env.have_h3_server(), reason="no h3 server")
@pytest.mark.skipif(condition=True, reason="2nd and 3rd request sometimes fail")
def test_03_02_h3_goaway(self, env: Env, httpd, nghttpx, repeat):
proto = 'h3'
count = 3
@ -95,12 +96,10 @@ class TestGoAway:
# each request will take a second, reload the server in the middle
# of the first one.
time.sleep(1.5)
assert nghttpx.reload(timeout=timedelta(seconds=5))
assert nghttpx.reload(timeout=timedelta(seconds=2))
t.join()
r: ExecResult = self.r
assert r.exit_code == 0, f'{r}'
r.check_responses(count=count, exp_status=200)
assert len(r.stats) == count, f'{r.stats}'
# reload will shut down the connection gracefully with GOAWAY
# we expect to see a second connection opened afterwards
assert r.total_connects == 2
@ -109,5 +108,6 @@ class TestGoAway:
log.debug(f'request {idx} connected')
# this should take `count` seconds to retrieve
assert r.duration >= timedelta(seconds=count)
r.check_stats(count=count, exp_status=200, exp_exitcode=0)

View file

@ -38,6 +38,11 @@ log = logging.getLogger(__name__)
reason=f"missing: {Env.incomplete_reason()}")
class TestStuttered:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
# download 1 file, check that delayed response works in general
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_04_01_download_1(self, env: Env, httpd, nghttpx, repeat,
@ -51,7 +56,7 @@ class TestStuttered:
'&chunks=100&chunk_size=100&chunk_delay=10ms'
r = curl.http_download(urls=[urln], alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_responses(count=1, exp_status=200)
r.check_stats(count=1, exp_status=200)
# download 50 files in 100 chunks a 100 bytes with 10ms delay between
# prepend 100 file requests to warm up connection processing limits
@ -71,11 +76,11 @@ class TestStuttered:
r = curl.http_download(urls=[url1, urln], alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0, f'{r}'
r.check_responses(count=warmups+count, exp_status=200)
r.check_stats(count=warmups+count, exp_status=200)
assert r.total_connects == 1
t_avg, i_min, t_min, i_max, t_max = self.stats_spread(r.stats[warmups:], 'time_total')
assert t_max < (3 * t_min) and t_min < 2, \
f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]'
if t_max < (5 * t_min) and t_min < 2:
log.warning(f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]')
# download 50 files in 1000 chunks a 10 bytes with 1ms delay between
# prepend 100 file requests to warm up connection processing limits
@ -94,11 +99,11 @@ class TestStuttered:
r = curl.http_download(urls=[url1, urln], alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0
r.check_responses(count=warmups+count, exp_status=200)
r.check_stats(count=warmups+count, exp_status=200)
assert r.total_connects == 1
t_avg, i_min, t_min, i_max, t_max = self.stats_spread(r.stats[warmups:], 'time_total')
assert t_max < (2 * t_min), \
f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]'
if t_max < (5 * t_min):
log.warning(f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]')
# download 50 files in 10000 chunks a 1 byte with 10us delay between
# prepend 100 file requests to warm up connection processing limits
@ -107,8 +112,6 @@ class TestStuttered:
def test_04_04_1000_10_1(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
if proto == 'h2':
pytest.skip("h2 shows overly long request times")
count = 50
warmups = 100
curl = CurlClient(env=env)
@ -119,11 +122,11 @@ class TestStuttered:
r = curl.http_download(urls=[url1, urln], alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0
r.check_responses(count=warmups+count, exp_status=200)
r.check_stats(count=warmups+count, exp_status=200)
assert r.total_connects == 1
t_avg, i_min, t_min, i_max, t_max = self.stats_spread(r.stats[warmups:], 'time_total')
assert t_max < (2 * t_min), \
f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]'
if t_max < (5 * t_min):
log.warning(f'avg time of transfer: {t_avg} [{i_min}={t_min}, {i_max}={t_max}]')
def stats_spread(self, stats: List[Dict], key: str) -> Tuple[float, int, float, int, float]:
stotals = 0.0

View file

@ -37,18 +37,21 @@ log = logging.getLogger(__name__)
@pytest.mark.skipif(condition=Env.setup_incomplete(),
reason=f"missing: {Env.incomplete_reason()}")
@pytest.mark.skipif(condition=not Env.httpd_is_at_least('2.4.55'),
reason=f"httpd version too old for this: {Env.httpd_version()}")
class TestErrors:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
# download 1 file, check that we get CURLE_PARTIAL_FILE
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_05_01_partial_1(self, env: Env, httpd, nghttpx, repeat,
proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
if proto == 'h2': # TODO, fix error code in curl
pytest.skip("h2 reports exitcode 16(CURLE_HTTP2)")
if proto == 'h3': # TODO, fix error code in curl
pytest.skip("h3 reports exitcode 95(CURLE_HTTP3)")
count = 1
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, proto)}' \
@ -58,7 +61,7 @@ class TestErrors:
assert r.exit_code != 0, f'{r}'
invalid_stats = []
for idx, s in enumerate(r.stats):
if 'exitcode' not in s or s['exitcode'] != 18:
if 'exitcode' not in s or s['exitcode'] not in [18, 56]:
invalid_stats.append(f'request {idx} exit with {s["exitcode"]}')
assert len(invalid_stats) == 0, f'failed: {invalid_stats}'
@ -68,10 +71,6 @@ class TestErrors:
proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
if proto == 'h2': # TODO, fix error code in curl
pytest.skip("h2 reports exitcode 16(CURLE_HTTP2)")
if proto == 'h3': # TODO, fix error code in curl
pytest.skip("h3 reports exitcode 95(CURLE_HTTP3) and takes a long time")
count = 20
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, proto)}' \
@ -82,6 +81,6 @@ class TestErrors:
assert len(r.stats) == count, f'did not get all stats: {r}'
invalid_stats = []
for idx, s in enumerate(r.stats):
if 'exitcode' not in s or s['exitcode'] != 18:
if 'exitcode' not in s or s['exitcode'] not in [18, 56]:
invalid_stats.append(f'request {idx} exit with {s["exitcode"]}')
assert len(invalid_stats) == 0, f'failed: {invalid_stats}'

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#***************************************************************************
# _ _ ____ _
# Project ___| | | | _ \| |
# / __| | | | |_) | |
# | (__| |_| | _ <| |___
# \___|\___/|_| \_\_____|
#
# Copyright (C) 2008 - 2022, 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 json
import logging
from typing import Optional, Tuple, List, Dict
import pytest
from testenv import Env, CurlClient, ExecResult
log = logging.getLogger(__name__)
@pytest.mark.skipif(condition=Env.setup_incomplete(),
reason=f"missing: {Env.incomplete_reason()}")
@pytest.mark.skipif(condition=not Env.have_h3_server(),
reason=f"missing HTTP/3 server")
@pytest.mark.skipif(condition=not Env.have_h3_curl(),
reason=f"curl built without HTTP/3")
class TestEyeballs:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
# download using only HTTP/3 on working server
def test_06_01_h3_only(self, env: Env, httpd, nghttpx, repeat):
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json'
r = curl.http_download(urls=[urln], extra_args=['--http3-only'])
assert r.exit_code == 0, f'{r}'
r.check_stats(count=1, exp_status=200)
assert r.stats[0]['http_version'] == '3'
# download using only HTTP/3 on missing server
def test_06_02_h3_only(self, env: Env, httpd, nghttpx, repeat):
nghttpx.stop_if_running()
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json'
r = curl.http_download(urls=[urln], extra_args=['--http3-only'])
assert r.exit_code == 7, f'{r}' # could not connect
# download using HTTP/3 on missing server with fallback on h2
def test_06_03_h3_fallback_h2(self, env: Env, httpd, nghttpx, repeat):
nghttpx.stop_if_running()
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json'
r = curl.http_download(urls=[urln], extra_args=['--http3'])
assert r.exit_code == 0, f'{r}'
r.check_stats(count=1, exp_status=200)
assert r.stats[0]['http_version'] == '2'
# download using HTTP/3 on missing server with fallback on http/1.1
def test_06_04_h3_fallback_h1(self, env: Env, httpd, nghttpx, repeat):
nghttpx.stop_if_running()
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain2, "h3")}/data.json'
r = curl.http_download(urls=[urln], extra_args=['--http3'])
assert r.exit_code == 0, f'{r}'
r.check_stats(count=1, exp_status=200)
assert r.stats[0]['http_version'] == '1.1'

View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#***************************************************************************
# _ _ ____ _
# Project ___| | | | _ \| |
# / __| | | | |_) | |
# | (__| |_| | _ <| |___
# \___|\___/|_| \_\_____|
#
# Copyright (C) 2008 - 2022, 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 logging
import os
import pytest
from testenv import Env, CurlClient
log = logging.getLogger(__name__)
@pytest.mark.skipif(condition=Env.setup_incomplete(),
reason=f"missing: {Env.incomplete_reason()}")
class TestUpload:
@pytest.fixture(autouse=True, scope='class')
def _class_scope(self, env, nghttpx):
if env.have_h3():
nghttpx.start_if_needed()
s90 = "01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678\n"
with open(os.path.join(env.gen_dir, "data-100k"), 'w') as f:
for i in range(1000):
f.write(f"{i:09d}-{s90}")
# upload small data, check that this is what was echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_01_upload_1_small(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
data = '0123456789'
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-0]'
r = curl.http_upload(urls=[url], data=data, alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_stats(count=1, exp_status=200)
respdata = open(curl.response_file(0)).readlines()
assert respdata == [data]
# upload large data, check that this is what was echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_02_upload_1_large(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
fdata = os.path.join(env.gen_dir, 'data-100k')
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-0]'
r = curl.http_upload(urls=[url], data=f'@{fdata}', alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_stats(count=1, exp_status=200)
indata = open(fdata).readlines()
respdata = open(curl.response_file(0)).readlines()
assert respdata == indata
# upload data sequentially, check that they were echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_10_upload_sequential(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
count = 50
data = '0123456789'
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-{count-1}]'
r = curl.http_upload(urls=[url], data=data, alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_stats(count=count, exp_status=200)
for i in range(count):
respdata = open(curl.response_file(i)).readlines()
assert respdata == [data]
# upload large data sequentially, check that this is what was echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_11_upload_seq_large(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
fdata = os.path.join(env.gen_dir, 'data-100k')
count = 50
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-{count-1}]'
r = curl.http_upload(urls=[url], data=f'@{fdata}', alpn_proto=proto)
assert r.exit_code == 0, f'{r}'
r.check_stats(count=count, exp_status=200)
indata = open(fdata).readlines()
r.check_stats(count=count, exp_status=200)
for i in range(count):
respdata = open(curl.response_file(i)).readlines()
assert respdata == indata
# upload data parallel, check that they were echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_20_upload_parallel(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
count = 50
data = '0123456789'
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-{count-1}]'
r = curl.http_upload(urls=[url], data=data, alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0, f'{r}'
r.check_stats(count=count, exp_status=200)
for i in range(count):
respdata = open(curl.response_file(i)).readlines()
assert respdata == [data]
# upload large data parallel, check that this is what was echoed
@pytest.mark.parametrize("proto", ['http/1.1', 'h2', 'h3'])
def test_07_21_upload_parallel_large(self, env: Env, httpd, nghttpx, repeat, proto):
if proto == 'h3' and not env.have_h3():
pytest.skip("h3 not supported")
if proto == 'h3' and env.curl_uses_lib('quiche'):
pytest.skip("quiche stalls on parallel, large uploads")
fdata = os.path.join(env.gen_dir, 'data-100k')
count = 3
curl = CurlClient(env=env)
url = f'https://{env.authority_for(env.domain1, proto)}/curltest/echo?id=[0-{count-1}]'
r = curl.http_upload(urls=[url], data=f'@{fdata}', alpn_proto=proto,
extra_args=['--parallel'])
assert r.exit_code == 0, f'{r}'
r.check_stats(count=count, exp_status=200)
indata = open(fdata).readlines()
r.check_stats(count=count, exp_status=200)
for i in range(count):
respdata = open(curl.response_file(i)).readlines()
assert respdata == indata

View file

@ -26,6 +26,7 @@
#
from .env import Env
from .certs import TestCA, Credentials
from .caddy import Caddy
from .httpd import Httpd
from .curl import CurlClient, ExecResult
from .nghttpx import Nghttpx

View file

@ -0,0 +1,164 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#***************************************************************************
# _ _ ____ _
# Project ___| | | | _ \| |
# / __| | | | |_) | |
# | (__| |_| | _ <| |___
# \___|\___/|_| \_\_____|
#
# Copyright (C) 2008 - 2022, 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 logging
import os
import subprocess
import time
from datetime import timedelta, datetime
from json import JSONEncoder
from .curl import CurlClient
from .env import Env
log = logging.getLogger(__name__)
class Caddy:
def __init__(self, env: Env):
self.env = env
self._caddy = os.environ['CADDY'] if 'CADDY' in os.environ else env.caddy
self._caddy_dir = os.path.join(env.gen_dir, 'caddy')
self._docs_dir = os.path.join(self._caddy_dir, 'docs')
self._conf_file = os.path.join(self._caddy_dir, 'Caddyfile')
self._error_log = os.path.join(self._caddy_dir, 'caddy.log')
self._tmp_dir = os.path.join(self._caddy_dir, 'tmp')
self._process = None
self._rmf(self._error_log)
@property
def docs_dir(self):
return self._docs_dir
def clear_logs(self):
self._rmf(self._error_log)
def is_running(self):
if self._process:
self._process.poll()
return self._process.returncode is None
return False
def start_if_needed(self):
if not self.is_running():
return self.start()
return True
def start(self, wait_live=True):
self._mkpath(self._tmp_dir)
if self._process:
self.stop()
self._write_config()
args = [
self._caddy, 'run'
]
caddyerr = open(self._error_log, 'a')
self._process = subprocess.Popen(args=args, cwd=self._caddy_dir, stderr=caddyerr)
if self._process.returncode is not None:
return False
return not wait_live or self.wait_live(timeout=timedelta(seconds=5))
def stop_if_running(self):
if self.is_running():
return self.stop()
return True
def stop(self, wait_dead=True):
self._mkpath(self._tmp_dir)
if self._process:
self._process.terminate()
self._process.wait(timeout=2)
self._process = None
return not wait_dead or self.wait_dead(timeout=timedelta(seconds=5))
return True
def restart(self):
self.stop()
return self.start()
def wait_dead(self, timeout: timedelta):
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
try_until = datetime.now() + timeout
while datetime.now() < try_until:
check_url = f'https://{self.env.domain1}:{self.env.caddy_port}/'
r = curl.http_get(url=check_url)
if r.exit_code != 0:
return True
log.debug(f'waiting for caddy to stop responding: {r}')
time.sleep(.1)
log.debug(f"Server still responding after {timeout}")
return False
def wait_live(self, timeout: timedelta):
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
try_until = datetime.now() + timeout
while datetime.now() < try_until:
check_url = f'https://{self.env.domain1}:{self.env.caddy_port}/'
r = curl.http_get(url=check_url)
if r.exit_code == 0:
return True
log.error(f'curl: {r}')
log.debug(f'waiting for caddy to become responsive: {r}')
time.sleep(.1)
log.error(f"Server still not responding after {timeout}")
return False
def _rmf(self, path):
if os.path.exists(path):
return os.remove(path)
def _mkpath(self, path):
if not os.path.exists(path):
return os.makedirs(path)
def _write_config(self):
domain1 = self.env.domain1
creds1 = self.env.get_credentials(domain1)
self._mkpath(self._docs_dir)
self._mkpath(self._tmp_dir)
with open(os.path.join(self._docs_dir, 'data.json'), 'w') as fd:
data = {
'server': f'{domain1}',
}
fd.write(JSONEncoder().encode(data))
with open(self._conf_file, 'w') as fd:
conf = [ # base server config
f'{{',
f' https_port {self.env.caddy_port}',
f' servers :{self.env.caddy_port} {{',
f' protocols h3 h2 h1',
f' }}',
f'}}',
f'{domain1}:{self.env.caddy_port} {{',
f' file_server * {{',
f' root {self._docs_dir}',
f' }}',
f' tls {creds1.cert_file} {creds1.pkey_file}',
f'}}',
]
fd.write("\n".join(conf))

View file

@ -155,28 +155,36 @@ class ExecResult:
def add_assets(self, assets: List):
self._assets.extend(assets)
def check_responses(self, count: int, exp_status: Optional[int] = None):
if len(self.responses) != count:
seen_queries = []
for idx, resp in enumerate(self.responses):
assert resp['status'] == 200, f'response #{idx} status: {resp["status"]}'
if 'rquery' not in resp['header']:
log.error(f'response #{idx} missing "rquery": {resp["header"]}')
seen_queries.append(int(resp['header']['rquery']))
for i in range(0, count-1):
if i not in seen_queries:
log.error(f'response for query {i} missing')
if self.with_stats and len(self.stats) == count:
log.error(f'got all {count} stats, though')
def check_responses(self, count: int, exp_status: Optional[int] = None,
exp_exitcode: Optional[int] = None):
assert len(self.responses) == count, \
f'response count: expected {count}, got {len(self.responses)}'
if exp_status is not None:
for idx, x in enumerate(self.responses):
assert x['status'] == exp_status, \
f'response #{idx} unexpectedstatus: {x["status"]}'
if exp_exitcode is not None:
for idx, x in enumerate(self.responses):
if 'exitcode' in x:
assert x['exitcode'] == 0, f'response #{idx} exitcode: {x["exitcode"]}'
if self.with_stats:
assert len(self.stats) == count, f'{self}'
def check_stats(self, count: int, exp_status: Optional[int] = None,
exp_exitcode: Optional[int] = None):
assert len(self.stats) == count, \
f'stats count: expected {count}, got {len(self.stats)}'
if exp_status is not None:
for idx, x in enumerate(self.stats):
assert 'http_code' in x, \
f'status #{idx} reports no http_code'
assert x['http_code'] == exp_status, \
f'status #{idx} unexpected http_code: {x["http_code"]}'
if exp_exitcode is not None:
for idx, x in enumerate(self.stats):
if 'exitcode' in x:
assert x['exitcode'] == 0, f'status #{idx} exitcode: {x["exitcode"]}'
class CurlClient:
@ -186,7 +194,7 @@ class CurlClient:
'http/1.1': '--http1.1',
'h2': '--http2',
'h2c': '--http2',
'h3': '--http3',
'h3': '--http3-only',
}
def __init__(self, env: Env, run_dir: Optional[str] = None):
@ -219,6 +227,7 @@ class CurlClient:
def http_download(self, urls: List[str],
alpn_proto: Optional[str] = None,
with_stats: bool = True,
with_headers: bool = False,
extra_args: List[str] = None):
if extra_args is None:
extra_args = []
@ -230,7 +239,41 @@ class CurlClient:
'-w', '%{json}\\n'
])
return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
with_stats=with_stats)
with_stats=with_stats,
with_headers=with_headers)
def http_upload(self, urls: List[str], data: str,
alpn_proto: Optional[str] = None,
with_stats: bool = True,
with_headers: bool = False,
extra_args: Optional[List[str]] = None):
if extra_args is None:
extra_args = []
extra_args.extend([
'--data-binary', data, '-o', 'download_#1.data',
])
if with_stats:
extra_args.extend([
'-w', '%{json}\\n'
])
return self._raw(urls, alpn_proto=alpn_proto, options=extra_args,
with_stats=with_stats,
with_headers=with_headers)
def response_file(self, idx: int):
return os.path.join(self._run_dir, f'download_{idx}.data')
def run_direct(self, args, with_stats: bool = False):
my_args = [self._curl]
if with_stats:
my_args.extend([
'-w', '%{json}\\n'
])
my_args.extend([
'-o', 'download.data',
])
my_args.extend(args)
return self._run(args=my_args, with_stats=with_stats)
def _run(self, args, intext='', with_stats: bool = False):
self._rmf(self._stdoutfile)
@ -252,12 +295,15 @@ class CurlClient:
def _raw(self, urls, timeout=10, options=None, insecure=False,
alpn_proto: Optional[str] = None,
force_resolve=True, with_stats=False):
force_resolve=True,
with_stats=False,
with_headers=True):
args = self._complete_args(
urls=urls, timeout=timeout, options=options, insecure=insecure,
alpn_proto=alpn_proto, force_resolve=force_resolve)
alpn_proto=alpn_proto, force_resolve=force_resolve,
with_headers=with_headers)
r = self._run(args, with_stats=with_stats)
if r.exit_code == 0:
if r.exit_code == 0 and with_headers:
self._parse_headerfile(self._headerfile, r=r)
if r.json:
r.response["json"] = r.json
@ -265,13 +311,14 @@ class CurlClient:
def _complete_args(self, urls, timeout=None, options=None,
insecure=False, force_resolve=True,
alpn_proto: Optional[str] = None):
alpn_proto: Optional[str] = None,
with_headers: bool = True):
if not isinstance(urls, list):
urls = [urls]
args = [
self._curl, "-s", "--path-as-is", "-D", self._headerfile,
]
args = [self._curl, "-s", "--path-as-is"]
if with_headers:
args.extend(["-D", self._headerfile])
if self.env.verbose > 2:
args.extend(['--trace', self._tracefile, '--trace-time'])

View file

@ -59,19 +59,41 @@ class EnvConfig:
self.config = DEF_CONFIG
# check cur and its features
self.curl = CURL
self.curl_features = []
self.curl_props = {
'version': None,
'os': None,
'features': [],
'protocols': [],
'libs': [],
'lib_versions': [],
}
self.curl_protos = []
p = subprocess.run(args=[self.curl, '-V'],
capture_output=True, text=True)
if p.returncode != 0:
assert False, f'{self.curl} -V failed with exit code: {p.returncode}'
for l in p.stdout.splitlines(keepends=False):
if l.startswith('curl '):
m = re.match(r'^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$', l)
if m:
self.curl_props['version'] = m.group('version')
self.curl_props['os'] = m.group('os')
self.curl_props['lib_versions'] = [
lib.lower() for lib in m.group('libs').split(' ')
]
self.curl_props['libs'] = [
re.sub(r'/.*', '',lib) for lib in self.curl_props['lib_versions']
]
if l.startswith('Features: '):
self.curl_features = [feat.lower() for feat in l[10:].split(' ')]
self.curl_props['features'] = [
feat.lower() for feat in l[10:].split(' ')
]
if l.startswith('Protocols: '):
self.curl_protos = [prot.lower() for prot in l[11:].split(' ')]
self.curl_props['protocols'] = [
prot.lower() for prot in l[11:].split(' ')
]
self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip())
log.error(f'nghttpx -v: {p.stdout}')
log.debug(f'nghttpx -v: {p.stdout}')
self.http_port = self.config['test']['http_port']
self.https_port = self.config['test']['https_port']
@ -81,6 +103,7 @@ class EnvConfig:
self.apxs = self.config['httpd']['apxs']
if len(self.apxs) == 0:
self.apxs = None
self._httpd_version = None
self.examples_pem = {
'key': 'xxx',
@ -110,7 +133,39 @@ class EnvConfig:
self.nghttpx = None
else:
self.nghttpx_with_h3 = re.match(r'.* nghttp3/.*', p.stdout.strip()) is not None
log.error(f'nghttpx -v: {p.stdout}')
log.debug(f'nghttpx -v: {p.stdout}')
self.caddy = self.config['caddy']['caddy']
if len(self.caddy) == 0:
self.caddy = 'caddy'
if self.caddy is not None:
try:
p = subprocess.run(args=[self.caddy, 'version'],
capture_output=True, text=True)
if p.returncode != 0:
# not a working caddy
self.caddy = None
except:
self.caddy = None
self.caddy_port = self.config['caddy']['port']
@property
def httpd_version(self):
if self._httpd_version is None and self.apxs is not None:
p = subprocess.run(args=[self.apxs, '-q', 'HTTPD_VERSION'],
capture_output=True, text=True)
if p.returncode != 0:
raise Exception(f'{self.apxs} failed to query HTTPD_VERSION: {p}')
self._httpd_version = p.stdout.strip()
return self._httpd_version
def _versiontuple(self, v):
v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v)
return tuple(map(int, v.split('.')))
def httpd_is_at_least(self, minv):
hv = self._versiontuple(self.httpd_version)
return hv >= self._versiontuple(minv)
def is_complete(self) -> bool:
return os.path.isfile(self.httpd) and \
@ -146,14 +201,46 @@ class Env:
def have_h3_server() -> bool:
return Env.CONFIG.nghttpx_with_h3
@staticmethod
def have_h2_curl() -> bool:
return 'http2' in Env.CONFIG.curl_props['features']
@staticmethod
def have_h3_curl() -> bool:
return 'http3' in Env.CONFIG.curl_features
return 'http3' in Env.CONFIG.curl_props['features']
@staticmethod
def curl_uses_lib(libname: str) -> bool:
return libname.lower() in Env.CONFIG.curl_props['libs']
@staticmethod
def curl_lib_version(libname: str) -> str:
prefix = f'{libname.lower()}/'
for lversion in Env.CONFIG.curl_props['lib_versions']:
if lversion.startswith(prefix):
return lversion[len(prefix):]
return 'unknown'
@staticmethod
def curl_os() -> bool:
return Env.CONFIG.curl_props['os']
@staticmethod
def curl_version() -> bool:
return Env.CONFIG.curl_props['version']
@staticmethod
def have_h3() -> bool:
return Env.have_h3_curl() and Env.have_h3_server()
@staticmethod
def httpd_version() -> str:
return Env.CONFIG.httpd_version
@staticmethod
def httpd_is_at_least(minv) -> bool:
return Env.CONFIG.httpd_is_at_least(minv)
def __init__(self, pytestconfig=None):
self._verbose = pytestconfig.option.verbose \
if pytestconfig is not None else 0
@ -214,6 +301,14 @@ class Env:
def h3_port(self) -> str:
return self.CONFIG.h3_port
@property
def caddy(self) -> str:
return self.CONFIG.caddy
@property
def caddy_port(self) -> str:
return self.CONFIG.caddy_port
@property
def curl(self) -> str:
return self.CONFIG.curl

View file

@ -69,22 +69,24 @@ class Httpd:
self._error_log = os.path.join(self._logs_dir, 'error_log')
self._tmp_dir = os.path.join(self._apache_dir, 'tmp')
self._mods_dir = None
if env.apxs is not None:
p = subprocess.run(args=[env.apxs, '-q', 'libexecdir'],
capture_output=True, text=True)
if p.returncode != 0:
raise Exception(f'{env.apxs} failed to query libexecdir: {p}')
self._mods_dir = p.stdout.strip()
else:
for md in self.COMMON_MODULES_DIRS:
if os.path.isdir(md):
self._mods_dir = md
assert env.apxs
p = subprocess.run(args=[env.apxs, '-q', 'libexecdir'],
capture_output=True, text=True)
if p.returncode != 0:
raise Exception(f'{env.apxs} failed to query libexecdir: {p}')
self._mods_dir = p.stdout.strip()
if self._mods_dir is None:
raise Exception(f'apache modules dir cannot be found')
if not os.path.exists(self._mods_dir):
raise Exception(f'apache modules dir does not exist: {self._mods_dir}')
self._process = None
self._rmf(self._error_log)
self._init_curltest()
@property
def docs_dir(self):
return self._docs_dir
def clear_logs(self):
self._rmf(self._error_log)
@ -213,9 +215,6 @@ class Httpd:
f'Listen {self.env.http_port}',
f'Listen {self.env.https_port}',
f'TypesConfig "{self._conf_dir}/mime.types',
# we want the quest string in a response header, so we
# can check responses more easily
f'Header set rquery "%{{QUERY_STRING}}s"',
]
conf.extend([ # plain http host for domain1
f'<VirtualHost *:{self.env.http_port}>',

View file

@ -24,15 +24,16 @@
#
###########################################################################
#
import datetime
import logging
import os
import signal
import subprocess
import time
from typing import Optional
from datetime import datetime, timedelta
from .env import Env
from .curl import CurlClient
log = logging.getLogger(__name__)
@ -43,12 +44,18 @@ class Nghttpx:
def __init__(self, env: Env):
self.env = env
self._cmd = env.nghttpx
self._pid_file = os.path.join(env.gen_dir, 'nghttpx.pid')
self._conf_file = os.path.join(env.gen_dir, 'nghttpx.conf')
self._error_log = os.path.join(env.gen_dir, 'nghttpx.log')
self._stderr = os.path.join(env.gen_dir, 'nghttpx.stderr')
self._run_dir = os.path.join(env.gen_dir, 'nghttpx')
self._pid_file = os.path.join(self._run_dir, 'nghttpx.pid')
self._conf_file = os.path.join(self._run_dir, 'nghttpx.conf')
self._error_log = os.path.join(self._run_dir, 'nghttpx.log')
self._stderr = os.path.join(self._run_dir, 'nghttpx.stderr')
self._tmp_dir = os.path.join(self._run_dir, 'tmp')
self._process = None
self._process: Optional[subprocess.Popen] = None
self._rmf(self._pid_file)
self._rmf(self._error_log)
self._mkpath(self._run_dir)
self._write_config()
def exists(self):
return os.path.exists(self._cmd)
@ -63,10 +70,15 @@ class Nghttpx:
return self._process.returncode is None
return False
def start(self):
def start_if_needed(self):
if not self.is_running():
return self.start()
return True
def start(self, wait_live=True):
self._mkpath(self._tmp_dir)
if self._process:
self.stop()
self._write_config()
args = [
self._cmd,
f'--frontend=*,{self.env.h3_port};quic',
@ -82,31 +94,78 @@ class Nghttpx:
]
ngerr = open(self._stderr, 'a')
self._process = subprocess.Popen(args=args, stderr=ngerr)
return self._process.returncode is None
if self._process.returncode is not None:
return False
return not wait_live or self.wait_live(timeout=timedelta(seconds=5))
def stop(self):
def stop_if_running(self):
if self.is_running():
return self.stop()
return True
def stop(self, wait_dead=True):
self._mkpath(self._tmp_dir)
if self._process:
self._process.terminate()
self._process.wait(timeout=2)
self._process = None
return not wait_dead or self.wait_dead(timeout=timedelta(seconds=5))
return True
def restart(self):
self.stop()
return self.start()
def reload(self, timeout: datetime.timedelta):
def reload(self, timeout: timedelta):
if self._process:
running = self._process
self._process = None
os.kill(running.pid, signal.SIGQUIT)
self.start()
try:
log.debug(f'waiting for nghttpx({running.pid}) to exit.')
running.wait(timeout=timeout.seconds)
log.debug(f'nghttpx({running.pid}) terminated -> {running.returncode}')
end_wait = datetime.now() + timeout
if not self.start(wait_live=False):
self._process = running
return False
while datetime.now() < end_wait:
try:
log.debug(f'waiting for nghttpx({running.pid}) to exit.')
running.wait(2)
log.debug(f'nghttpx({running.pid}) terminated -> {running.returncode}')
break
except subprocess.TimeoutExpired:
log.warning(f'nghttpx({running.pid}), not shut down yet.')
os.kill(running.pid, signal.SIGQUIT)
if datetime.now() >= end_wait:
log.error(f'nghttpx({running.pid}), terminate forcefully.')
os.kill(running.pid, signal.SIGKILL)
running.terminate()
running.wait(1)
return self.wait_live(timeout=timedelta(seconds=5))
return False
def wait_dead(self, timeout: timedelta):
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
try_until = datetime.now() + timeout
while datetime.now() < try_until:
check_url = f'https://{self.env.domain1}:{self.env.h3_port}/'
r = curl.http_get(url=check_url, extra_args=['--http3-only'])
if r.exit_code != 0:
return True
except subprocess.TimeoutExpired:
log.error(f'SIGQUIT nghttpx({running.pid}), but did not shut down.')
log.debug(f'waiting for nghttpx to stop responding: {r}')
time.sleep(.1)
log.debug(f"Server still responding after {timeout}")
return False
def wait_live(self, timeout: timedelta):
curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
try_until = datetime.now() + timeout
while datetime.now() < try_until:
check_url = f'https://{self.env.domain1}:{self.env.h3_port}/'
r = curl.http_get(url=check_url, extra_args=['--http3-only'])
if r.exit_code == 0:
return True
log.debug(f'waiting for nghttpx to become responsive: {r}')
time.sleep(.1)
log.error(f"Server still not responding after {timeout}")
return False
def _rmf(self, path):