xref: /curl/tests/http/scorecard.py (revision fc3e1cbc)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3#***************************************************************************
4#                                  _   _ ____  _
5#  Project                     ___| | | |  _ \| |
6#                             / __| | | | |_) | |
7#                            | (__| |_| |  _ <| |___
8#                             \___|\___/|_| \_\_____|
9#
10# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
11#
12# This software is licensed as described in the file COPYING, which
13# you should have received as part of this distribution. The terms
14# are also available at https://curl.se/docs/copyright.html.
15#
16# You may opt to use, copy, modify, merge, publish, distribute and/or sell
17# copies of the Software, and permit persons to whom the Software is
18# furnished to do so, under the terms of the COPYING file.
19#
20# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
21# KIND, either express or implied.
22#
23# SPDX-License-Identifier: curl
24#
25###########################################################################
26#
27import argparse
28import json
29import logging
30import os
31import re
32import sys
33from statistics import mean
34from typing import Dict, Any, Optional, List
35
36from testenv import Env, Httpd, CurlClient, Caddy, ExecResult, NghttpxQuic, RunProfile
37
38log = logging.getLogger(__name__)
39
40
41class ScoreCardError(Exception):
42    pass
43
44
45class ScoreCard:
46
47    def __init__(self, env: Env,
48                 protocol: str,
49                 server_descr: str,
50                 server_port: int,
51                 verbose: int,
52                 curl_verbose: int,
53                 download_parallel: int = 0,
54                 server_addr: Optional[str] = None):
55        self.verbose = verbose
56        self.env = env
57        self.protocol = protocol
58        self.server_descr = server_descr
59        self.server_addr = server_addr
60        self.server_port = server_port
61        self._silent_curl = not curl_verbose
62        self._download_parallel = download_parallel
63
64    def info(self, msg):
65        if self.verbose > 0:
66            sys.stderr.write(msg)
67            sys.stderr.flush()
68
69    def handshakes(self) -> Dict[str, Any]:
70        props = {}
71        sample_size = 5
72        self.info('TLS Handshake\n')
73        for authority in [
74            'curl.se', 'google.com', 'cloudflare.com', 'nghttp2.org'
75        ]:
76            self.info(f'  {authority}...')
77            props[authority] = {}
78            for ipv in ['ipv4', 'ipv6']:
79                self.info(f'{ipv}...')
80                c_samples = []
81                hs_samples = []
82                errors = []
83                for _ in range(sample_size):
84                    curl = CurlClient(env=self.env, silent=self._silent_curl,
85                                      server_addr=self.server_addr)
86                    args = [
87                        '--http3-only' if self.protocol == 'h3' else '--http2',
88                        f'--{ipv}', f'https://{authority}/'
89                    ]
90                    r = curl.run_direct(args=args, with_stats=True)
91                    if r.exit_code == 0 and len(r.stats) == 1:
92                        c_samples.append(r.stats[0]['time_connect'])
93                        hs_samples.append(r.stats[0]['time_appconnect'])
94                    else:
95                        errors.append(f'exit={r.exit_code}')
96                    props[authority][f'{ipv}-connect'] = mean(c_samples) \
97                        if len(c_samples) else -1
98                    props[authority][f'{ipv}-handshake'] = mean(hs_samples) \
99                        if len(hs_samples) else -1
100                    props[authority][f'{ipv}-errors'] = errors
101            self.info('ok.\n')
102        return props
103
104    def _make_docs_file(self, docs_dir: str, fname: str, fsize: int):
105        fpath = os.path.join(docs_dir, fname)
106        data1k = 1024*'x'
107        flen = 0
108        with open(fpath, 'w') as fd:
109            while flen < fsize:
110                fd.write(data1k)
111                flen += len(data1k)
112        return fpath
113
114    def setup_resources(self, server_docs: str,
115                        downloads: Optional[List[int]] = None):
116        for fsize in downloads:
117            label = self.fmt_size(fsize)
118            fname = f'score{label}.data'
119            self._make_docs_file(docs_dir=server_docs,
120                                 fname=fname, fsize=fsize)
121        self._make_docs_file(docs_dir=server_docs,
122                             fname='reqs10.data', fsize=10*1024)
123
124    def _check_downloads(self, r: ExecResult, count: int):
125        error = ''
126        if r.exit_code != 0:
127            error += f'exit={r.exit_code} '
128        if r.exit_code != 0 or len(r.stats) != count:
129            error += f'stats={len(r.stats)}/{count} '
130        fails = [s for s in r.stats if s['response_code'] != 200]
131        if len(fails) > 0:
132            error += f'{len(fails)} failed'
133        return error if len(error) > 0 else None
134
135    def transfer_single(self, url: str, count: int):
136        sample_size = count
137        count = 1
138        samples = []
139        errors = []
140        profiles = []
141        self.info('single...')
142        for _ in range(sample_size):
143            curl = CurlClient(env=self.env, silent=self._silent_curl,
144                              server_addr=self.server_addr)
145            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
146                                   no_save=True, with_headers=False,
147                                   with_profile=True)
148            err = self._check_downloads(r, count)
149            if err:
150                errors.append(err)
151            else:
152                total_size = sum([s['size_download'] for s in r.stats])
153                samples.append(total_size / r.duration.total_seconds())
154                profiles.append(r.profile)
155        return {
156            'count': count,
157            'samples': sample_size,
158            'max-parallel': 1,
159            'speed': mean(samples) if len(samples) else -1,
160            'errors': errors,
161            'stats': RunProfile.AverageStats(profiles),
162        }
163
164    def transfer_serial(self, url: str, count: int):
165        sample_size = 1
166        samples = []
167        errors = []
168        profiles = []
169        url = f'{url}?[0-{count - 1}]'
170        self.info('serial...')
171        for _ in range(sample_size):
172            curl = CurlClient(env=self.env, silent=self._silent_curl,
173                              server_addr=self.server_addr)
174            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
175                                   no_save=True,
176                                   with_headers=False, with_profile=True)
177            err = self._check_downloads(r, count)
178            if err:
179                errors.append(err)
180            else:
181                total_size = sum([s['size_download'] for s in r.stats])
182                samples.append(total_size / r.duration.total_seconds())
183                profiles.append(r.profile)
184        return {
185            'count': count,
186            'samples': sample_size,
187            'max-parallel': 1,
188            'speed': mean(samples) if len(samples) else -1,
189            'errors': errors,
190            'stats': RunProfile.AverageStats(profiles),
191        }
192
193    def transfer_parallel(self, url: str, count: int):
194        sample_size = 1
195        samples = []
196        errors = []
197        profiles = []
198        max_parallel = self._download_parallel if self._download_parallel > 0 else count
199        url = f'{url}?[0-{count - 1}]'
200        self.info('parallel...')
201        for _ in range(sample_size):
202            curl = CurlClient(env=self.env, silent=self._silent_curl,
203                              server_addr=self.server_addr)
204            r = curl.http_download(urls=[url], alpn_proto=self.protocol,
205                                   no_save=True,
206                                   with_headers=False,
207                                   with_profile=True,
208                                   extra_args=[
209                                       '--parallel',
210                                       '--parallel-max', str(max_parallel)
211                                   ])
212            err = self._check_downloads(r, count)
213            if err:
214                errors.append(err)
215            else:
216                total_size = sum([s['size_download'] for s in r.stats])
217                samples.append(total_size / r.duration.total_seconds())
218                profiles.append(r.profile)
219        return {
220            'count': count,
221            'samples': sample_size,
222            'max-parallel': max_parallel,
223            'speed': mean(samples) if len(samples) else -1,
224            'errors': errors,
225            'stats': RunProfile.AverageStats(profiles),
226        }
227
228    def download_url(self, label: str, url: str, count: int):
229        self.info(f'  {count}x{label}: ')
230        props = {
231            'single': self.transfer_single(url=url, count=10),
232        }
233        if count > 1:
234            props['serial'] = self.transfer_serial(url=url, count=count)
235            props['parallel'] = self.transfer_parallel(url=url, count=count)
236        self.info('ok.\n')
237        return props
238
239    def downloads(self, count: int, fsizes: List[int]) -> Dict[str, Any]:
240        scores = {}
241        for fsize in fsizes:
242            label = self.fmt_size(fsize)
243            fname = f'score{label}.data'
244            url = f'https://{self.env.domain1}:{self.server_port}/{fname}'
245            scores[label] = self.download_url(label=label, url=url, count=count)
246        return scores
247
248    def _check_uploads(self, r: ExecResult, count: int):
249        error = ''
250        if r.exit_code != 0:
251            error += f'exit={r.exit_code} '
252        if r.exit_code != 0 or len(r.stats) != count:
253            error += f'stats={len(r.stats)}/{count} '
254        fails = [s for s in r.stats if s['response_code'] != 200]
255        if len(fails) > 0:
256            error += f'{len(fails)} failed'
257        for f in fails:
258            error += f'[{f["response_code"]}]'
259        return error if len(error) > 0 else None
260
261    def upload_single(self, url: str, fpath: str, count: int):
262        sample_size = count
263        count = 1
264        samples = []
265        errors = []
266        profiles = []
267        self.info('single...')
268        for _ in range(sample_size):
269            curl = CurlClient(env=self.env, silent=self._silent_curl,
270                              server_addr=self.server_addr)
271            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
272                              with_headers=False, with_profile=True)
273            err = self._check_uploads(r, count)
274            if err:
275                errors.append(err)
276            else:
277                total_size = sum([s['size_upload'] for s in r.stats])
278                samples.append(total_size / r.duration.total_seconds())
279                profiles.append(r.profile)
280        return {
281            'count': count,
282            'samples': sample_size,
283            'max-parallel': 1,
284            'speed': mean(samples) if len(samples) else -1,
285            'errors': errors,
286            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
287        }
288
289    def upload_serial(self, url: str, fpath: str, count: int):
290        sample_size = 1
291        samples = []
292        errors = []
293        profiles = []
294        url = f'{url}?id=[0-{count - 1}]'
295        self.info('serial...')
296        for _ in range(sample_size):
297            curl = CurlClient(env=self.env, silent=self._silent_curl,
298                              server_addr=self.server_addr)
299            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
300                              with_headers=False, with_profile=True)
301            err = self._check_uploads(r, count)
302            if err:
303                errors.append(err)
304            else:
305                total_size = sum([s['size_upload'] for s in r.stats])
306                samples.append(total_size / r.duration.total_seconds())
307                profiles.append(r.profile)
308        return {
309            'count': count,
310            'samples': sample_size,
311            'max-parallel': 1,
312            'speed': mean(samples) if len(samples) else -1,
313            'errors': errors,
314            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
315        }
316
317    def upload_parallel(self, url: str, fpath: str, count: int):
318        sample_size = 1
319        samples = []
320        errors = []
321        profiles = []
322        max_parallel = count
323        url = f'{url}?id=[0-{count - 1}]'
324        self.info('parallel...')
325        for _ in range(sample_size):
326            curl = CurlClient(env=self.env, silent=self._silent_curl,
327                              server_addr=self.server_addr)
328            r = curl.http_put(urls=[url], fdata=fpath, alpn_proto=self.protocol,
329                              with_headers=False, with_profile=True,
330                              extra_args=[
331                                   '--parallel',
332                                    '--parallel-max', str(max_parallel)
333                              ])
334            err = self._check_uploads(r, count)
335            if err:
336                errors.append(err)
337            else:
338                total_size = sum([s['size_upload'] for s in r.stats])
339                samples.append(total_size / r.duration.total_seconds())
340                profiles.append(r.profile)
341        return {
342            'count': count,
343            'samples': sample_size,
344            'max-parallel': max_parallel,
345            'speed': mean(samples) if len(samples) else -1,
346            'errors': errors,
347            'stats': RunProfile.AverageStats(profiles) if len(profiles) else {},
348        }
349
350    def upload_url(self, label: str, url: str, fpath: str, count: int):
351        self.info(f'  {count}x{label}: ')
352        props = {
353            'single': self.upload_single(url=url, fpath=fpath, count=10),
354        }
355        if count > 1:
356            props['serial'] = self.upload_serial(url=url, fpath=fpath, count=count)
357            props['parallel'] = self.upload_parallel(url=url, fpath=fpath, count=count)
358        self.info('ok.\n')
359        return props
360
361    def uploads(self, count: int, fsizes: List[int]) -> Dict[str, Any]:
362        scores = {}
363        url = f'https://{self.env.domain2}:{self.server_port}/curltest/put'
364        fpaths = {}
365        for fsize in fsizes:
366            label = self.fmt_size(fsize)
367            fname = f'upload{label}.data'
368            fpaths[label] = self._make_docs_file(docs_dir=self.env.gen_dir,
369                                                 fname=fname, fsize=fsize)
370
371        for label, fpath in fpaths.items():
372            scores[label] = self.upload_url(label=label, url=url, fpath=fpath,
373                                            count=count)
374        return scores
375
376    def do_requests(self, url: str, count: int, max_parallel: int = 1):
377        sample_size = 1
378        samples = []
379        errors = []
380        profiles = []
381        url = f'{url}?[0-{count - 1}]'
382        extra_args = [
383            '-w', '%{response_code},\\n',
384        ]
385        if max_parallel > 1:
386            extra_args.extend([
387               '--parallel', '--parallel-max', str(max_parallel)
388            ])
389        self.info(f'{max_parallel}...')
390        for _ in range(sample_size):
391            curl = CurlClient(env=self.env, silent=self._silent_curl,
392                              server_addr=self.server_addr)
393            r = curl.http_download(urls=[url], alpn_proto=self.protocol, no_save=True,
394                                   with_headers=False, with_profile=True,
395                                   with_stats=False, extra_args=extra_args)
396            if r.exit_code != 0:
397                errors.append(f'exit={r.exit_code}')
398            else:
399                samples.append(count / r.duration.total_seconds())
400                non_200s = 0
401                for line in r.stdout.splitlines():
402                    if not line.startswith('200,'):
403                        non_200s += 1
404                if non_200s > 0:
405                    errors.append(f'responses != 200: {non_200s}')
406            profiles.append(r.profile)
407        return {
408            'count': count,
409            'samples': sample_size,
410            'speed': mean(samples) if len(samples) else -1,
411            'errors': errors,
412            'stats': RunProfile.AverageStats(profiles),
413        }
414
415    def requests_url(self, url: str, count: int):
416        self.info(f'  {url}: ')
417        props = {}
418        # 300 is max in curl, see tool_main.h
419        for m in [1, 6, 25, 50, 100, 300]:
420            props[str(m)] = self.do_requests(url=url, count=count, max_parallel=m)
421        self.info('ok.\n')
422        return props
423
424    def requests(self, req_count) -> Dict[str, Any]:
425        url = f'https://{self.env.domain1}:{self.server_port}/reqs10.data'
426        return {
427            'count': req_count,
428            '10KB': self.requests_url(url=url, count=req_count),
429        }
430
431    def score(self,
432              handshakes: bool = True,
433              downloads: Optional[List[int]] = None,
434              download_count: int = 50,
435              uploads: Optional[List[int]] = None,
436              upload_count: int = 50,
437              req_count=5000,
438              requests: bool = True):
439        self.info(f"scoring {self.protocol} against {self.server_descr}\n")
440        p = {}
441        if self.protocol == 'h3':
442            p['name'] = 'h3'
443            if not self.env.have_h3_curl():
444                raise ScoreCardError('curl does not support HTTP/3')
445            for lib in ['ngtcp2', 'quiche', 'msh3', 'nghttp3']:
446                if self.env.curl_uses_lib(lib):
447                    p['implementation'] = lib
448                    break
449        elif self.protocol == 'h2':
450            p['name'] = 'h2'
451            if not self.env.have_h2_curl():
452                raise ScoreCardError('curl does not support HTTP/2')
453            for lib in ['nghttp2']:
454                if self.env.curl_uses_lib(lib):
455                    p['implementation'] = lib
456                    break
457        elif self.protocol == 'h1' or self.protocol == 'http/1.1':
458            proto = 'http/1.1'
459            p['name'] = proto
460            p['implementation'] = 'native'
461        else:
462            raise ScoreCardError(f"unknown protocol: {self.protocol}")
463
464        if 'implementation' not in p:
465            raise ScoreCardError(f'did not recognized {p} lib')
466        p['version'] = Env.curl_lib_version(p['implementation'])
467
468        score = {
469            'curl': self.env.curl_fullname(),
470            'os': self.env.curl_os(),
471            'protocol': p,
472            'server': self.server_descr,
473        }
474        if handshakes:
475            score['handshakes'] = self.handshakes()
476        if downloads and len(downloads) > 0:
477            score['downloads'] = self.downloads(count=download_count,
478                                                fsizes=downloads)
479        if uploads and len(uploads) > 0:
480            score['uploads'] = self.uploads(count=upload_count,
481                                            fsizes=uploads)
482        if requests:
483            score['requests'] = self.requests(req_count=req_count)
484        self.info("\n")
485        return score
486
487    def fmt_ms(self, tval):
488        return f'{int(tval*1000)} ms' if tval >= 0 else '--'
489
490    def fmt_size(self, val):
491        if val >= (1024*1024*1024):
492            return f'{val / (1024*1024*1024):0.000f}GB'
493        elif val >= (1024 * 1024):
494            return f'{val / (1024*1024):0.000f}MB'
495        elif val >= 1024:
496            return f'{val / 1024:0.000f}KB'
497        else:
498            return f'{val:0.000f}B'
499
500    def fmt_mbs(self, val):
501        return f'{val/(1024*1024):0.000f} MB/s' if val >= 0 else '--'
502
503    def fmt_reqs(self, val):
504        return f'{val:0.000f} r/s' if val >= 0 else '--'
505
506    def print_score(self, score):
507        print(f'{score["protocol"]["name"].upper()} in {score["curl"]}')
508        if 'handshakes' in score:
509            print(f'{"Handshakes":<24} {"ipv4":25} {"ipv6":28}')
510            print(f'  {"Host":<17} {"Connect":>12} {"Handshake":>12} '
511                  f'{"Connect":>12} {"Handshake":>12}     {"Errors":<20}')
512            for key, val in score["handshakes"].items():
513                print(f'  {key:<17} {self.fmt_ms(val["ipv4-connect"]):>12} '
514                      f'{self.fmt_ms(val["ipv4-handshake"]):>12} '
515                      f'{self.fmt_ms(val["ipv6-connect"]):>12} '
516                      f'{self.fmt_ms(val["ipv6-handshake"]):>12}     '
517                      f'{"/".join(val["ipv4-errors"] + val["ipv6-errors"]):<20}'
518                      )
519        if 'downloads' in score:
520            # get the key names of all sizes and measurements made
521            sizes = []
522            measures = []
523            m_names = {}
524            mcol_width = 12
525            mcol_sw = 17
526            for sskey, ssval in score['downloads'].items():
527                if isinstance(ssval, str):
528                    continue
529                if sskey not in sizes:
530                    sizes.append(sskey)
531                for mkey, mval in score['downloads'][sskey].items():
532                    if mkey not in measures:
533                        measures.append(mkey)
534                        m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
535            print(f'Downloads from {score["server"]}')
536            print(f'  {"Size":>8}', end='')
537            for m in measures:
538                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
539            print(f' {"Errors":^20}')
540
541            for size in score['downloads']:
542                size_score = score['downloads'][size]
543                print(f'  {size:>8}', end='')
544                errors = []
545                for val in size_score.values():
546                    if 'errors' in val:
547                        errors.extend(val['errors'])
548                for m in measures:
549                    if m in size_score:
550                        print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
551                        s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
552                            f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
553                        print(f' {s:<{mcol_sw}}', end='')
554                    else:
555                        print(' '*mcol_width, end='')
556                if len(errors):
557                    print(f' {"/".join(errors):<20}')
558                else:
559                    print(f' {"-":^20}')
560
561        if 'uploads' in score:
562            # get the key names of all sizes and measurements made
563            sizes = []
564            measures = []
565            m_names = {}
566            mcol_width = 12
567            mcol_sw = 17
568            for sskey, ssval in score['uploads'].items():
569                if isinstance(ssval, str):
570                    continue
571                if sskey not in sizes:
572                    sizes.append(sskey)
573                for mkey, mval in ssval.items():
574                    if mkey not in measures:
575                        measures.append(mkey)
576                        m_names[mkey] = f'{mkey}({mval["count"]}x{mval["max-parallel"]})'
577
578            print(f'Uploads to {score["server"]}')
579            print(f'  {"Size":>8}', end='')
580            for m in measures:
581                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
582            print(f' {"Errors":^20}')
583
584            for size in sizes:
585                size_score = score['uploads'][size]
586                print(f'  {size:>8}', end='')
587                errors = []
588                for val in size_score.values():
589                    if 'errors' in val:
590                        errors.extend(val['errors'])
591                for m in measures:
592                    if m in size_score:
593                        print(f' {self.fmt_mbs(size_score[m]["speed"]):>{mcol_width}}', end='')
594                        stats = size_score[m]["stats"]
595                        if 'cpu' in stats:
596                            s = f'[{stats["cpu"]:>.1f}%/{self.fmt_size(stats["rss"])}]'
597                        else:
598                            s = '[???/???]'
599                        print(f' {s:<{mcol_sw}}', end='')
600                    else:
601                        print(' '*mcol_width, end='')
602                if len(errors):
603                    print(f' {"/".join(errors):<20}')
604                else:
605                    print(f' {"-":^20}')
606
607        if 'requests' in score:
608            sizes = []
609            measures = []
610            m_names = {}
611            mcol_width = 9
612            mcol_sw = 13
613            for sskey, ssval in score['requests'].items():
614                if isinstance(ssval, (str, int)):
615                    continue
616                if sskey not in sizes:
617                    sizes.append(sskey)
618                for mkey in score['requests'][sskey]:
619                    if mkey not in measures:
620                        measures.append(mkey)
621                        m_names[mkey] = f'{mkey}'
622
623            print('Requests (max parallel) to {score["server"]}')
624            print(f'  {"Size":>6} {"Reqs":>6}', end='')
625            for m in measures:
626                print(f' {m_names[m]:>{mcol_width}} {"[cpu/rss]":<{mcol_sw}}', end='')
627            print(f' {"Errors":^10}')
628
629            for size in sizes:
630                size_score = score['requests'][size]
631                count = score['requests']['count']
632                print(f'  {size:>6} {count:>6}', end='')
633                errors = []
634                for val in size_score.values():
635                    if 'errors' in val:
636                        errors.extend(val['errors'])
637                for m in measures:
638                    if m in size_score:
639                        print(f' {self.fmt_reqs(size_score[m]["speed"]):>{mcol_width}}', end='')
640                        s = f'[{size_score[m]["stats"]["cpu"]:>.1f}%'\
641                            f'/{self.fmt_size(size_score[m]["stats"]["rss"])}]'
642                        print(f' {s:<{mcol_sw}}', end='')
643                    else:
644                        print(' '*mcol_width, end='')
645                if len(errors):
646                    print(f' {"/".join(errors):<10}')
647                else:
648                    print(f' {"-":^10}')
649
650
651def parse_size(s):
652    m = re.match(r'(\d+)(mb|kb|gb)?', s, re.IGNORECASE)
653    if m is None:
654        raise Exception(f'unrecognized size: {s}')
655    size = int(m.group(1))
656    if not m.group(2):
657        pass
658    elif m.group(2).lower() == 'kb':
659        size *= 1024
660    elif m.group(2).lower() == 'mb':
661        size *= 1024 * 1024
662    elif m.group(2).lower() == 'gb':
663        size *= 1024 * 1024 * 1024
664    return size
665
666
667def main():
668    parser = argparse.ArgumentParser(prog='scorecard', description="""
669        Run a range of tests to give a scorecard for a HTTP protocol
670        'h3' or 'h2' implementation in curl.
671        """)
672    parser.add_argument("-v", "--verbose", action='count', default=1,
673                        help="log more output on stderr")
674    parser.add_argument("-j", "--json", action='store_true',
675                        default=False, help="print json instead of text")
676    parser.add_argument("-H", "--handshakes", action='store_true',
677                        default=False, help="evaluate handshakes only")
678    parser.add_argument("-d", "--downloads", action='store_true',
679                        default=False, help="evaluate downloads")
680    parser.add_argument("--download", action='append', type=str,
681                        default=None, help="evaluate download size")
682    parser.add_argument("--download-count", action='store', type=int,
683                        default=50, help="perform that many downloads")
684    parser.add_argument("--download-parallel", action='store', type=int,
685                        default=0, help="perform that many downloads in parallel (default all)")
686    parser.add_argument("-u", "--uploads", action='store_true',
687                        default=False, help="evaluate uploads")
688    parser.add_argument("--upload", action='append', type=str,
689                        default=None, help="evaluate upload size")
690    parser.add_argument("--upload-count", action='store', type=int,
691                        default=50, help="perform that many uploads")
692    parser.add_argument("-r", "--requests", action='store_true',
693                        default=False, help="evaluate requests")
694    parser.add_argument("--request-count", action='store', type=int,
695                        default=5000, help="perform that many requests")
696    parser.add_argument("--httpd", action='store_true', default=False,
697                        help="evaluate httpd server only")
698    parser.add_argument("--caddy", action='store_true', default=False,
699                        help="evaluate caddy server only")
700    parser.add_argument("--curl-verbose", action='store_true',
701                        default=False, help="run curl with `-v`")
702    parser.add_argument("protocol", default='h2', nargs='?',
703                        help="Name of protocol to score")
704    parser.add_argument("--start-only", action='store_true', default=False,
705                        help="only start the servers")
706    parser.add_argument("--remote", action='store', type=str,
707                        default=None, help="score against the remote server at <ip>:<port>")
708    args = parser.parse_args()
709
710    if args.verbose > 0:
711        console = logging.StreamHandler()
712        console.setLevel(logging.INFO)
713        console.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
714        logging.getLogger('').addHandler(console)
715
716    protocol = args.protocol
717    handshakes = True
718    downloads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
719    if args.download is not None:
720        downloads = []
721        for x in args.download:
722            downloads.extend([parse_size(s) for s in x.split(',')])
723
724    uploads = [1024 * 1024, 10 * 1024 * 1024, 100 * 1024 * 1024]
725    if args.upload is not None:
726        uploads = []
727        for x in args.upload:
728            uploads.extend([parse_size(s) for s in x.split(',')])
729
730    requests = True
731    if args.downloads or args.uploads or args.requests or args.handshakes:
732        handshakes = args.handshakes
733        if not args.downloads:
734            downloads = None
735        if not args.uploads:
736            uploads = None
737        requests = args.requests
738
739    test_httpd = protocol != 'h3'
740    test_caddy = True
741    if args.caddy or args.httpd:
742        test_caddy = args.caddy
743        test_httpd = args.httpd
744
745    rv = 0
746    env = Env()
747    env.setup()
748    env.test_timeout = None
749    httpd = None
750    nghttpx = None
751    caddy = None
752    try:
753        cards = []
754
755        if args.remote:
756            m = re.match(r'^(.+):(\d+)$', args.remote)
757            if m is None:
758                raise ScoreCardError(f'unable to parse ip:port from --remote {args.remote}')
759            test_httpd = False
760            test_caddy = False
761            remote_addr = m.group(1)
762            remote_port = int(m.group(2))
763            card = ScoreCard(env=env,
764                             protocol=protocol,
765                             server_descr=f'Server at {args.remote}',
766                             server_addr=remote_addr,
767                             server_port=remote_port,
768                             verbose=args.verbose, curl_verbose=args.curl_verbose,
769                             download_parallel=args.download_parallel)
770            cards.append(card)
771
772        if test_httpd:
773            httpd = Httpd(env=env)
774            assert httpd.exists(), \
775                f'httpd not found: {env.httpd}'
776            httpd.clear_logs()
777            server_docs = httpd.docs_dir
778            assert httpd.start()
779            if protocol == 'h3':
780                nghttpx = NghttpxQuic(env=env)
781                nghttpx.clear_logs()
782                assert nghttpx.start()
783                server_descr = f'nghttpx: https:{env.h3_port} [backend httpd: {env.httpd_version()}, https:{env.https_port}]'
784                server_port = env.h3_port
785            else:
786                server_descr = f'httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}'
787                server_port = env.https_port
788            card = ScoreCard(env=env,
789                             protocol=protocol,
790                             server_descr=server_descr,
791                             server_port=server_port,
792                             verbose=args.verbose, curl_verbose=args.curl_verbose,
793                             download_parallel=args.download_parallel)
794            card.setup_resources(server_docs, downloads)
795            cards.append(card)
796
797        if test_caddy and env.caddy:
798            backend = ''
799            if uploads and httpd is None:
800                backend = f' [backend httpd: {env.httpd_version()}, http:{env.http_port} https:{env.https_port}]'
801                httpd = Httpd(env=env)
802                assert httpd.exists(), \
803                    f'httpd not found: {env.httpd}'
804                httpd.clear_logs()
805                assert httpd.start()
806            caddy = Caddy(env=env)
807            caddy.clear_logs()
808            assert caddy.start()
809            server_descr = f'Caddy: {env.caddy_version()}, http:{env.caddy_http_port} https:{env.caddy_https_port}{backend}'
810            server_port = caddy.port
811            server_docs = caddy.docs_dir
812            card = ScoreCard(env=env,
813                             protocol=protocol,
814                             server_descr=server_descr,
815                             server_port=server_port,
816                             verbose=args.verbose, curl_verbose=args.curl_verbose,
817                             download_parallel=args.download_parallel)
818            card.setup_resources(server_docs, downloads)
819            cards.append(card)
820
821        if args.start_only:
822            print('started servers:')
823            for card in cards:
824                print(f'{card.server_descr}')
825            sys.stderr.write('press [RETURN] to finish')
826            sys.stderr.flush()
827            sys.stdin.readline()
828        else:
829            for card in cards:
830                score = card.score(handshakes=handshakes,
831                                   downloads=downloads,
832                                   download_count=args.download_count,
833                                   uploads=uploads,
834                                   upload_count=args.upload_count,
835                                   req_count=args.request_count,
836                                   requests=requests)
837                if args.json:
838                    print(json.JSONEncoder(indent=2).encode(score))
839                else:
840                    card.print_score(score)
841
842    except ScoreCardError as ex:
843        sys.stderr.write(f"ERROR: {ex}\n")
844        rv = 1
845    except KeyboardInterrupt:
846        log.warning("aborted")
847        rv = 1
848    finally:
849        if caddy:
850            caddy.stop()
851        if nghttpx:
852            nghttpx.stop(wait_dead=False)
853        if httpd:
854            httpd.stop()
855    sys.exit(rv)
856
857
858if __name__ == "__main__":
859    main()
860