import abc import filecmp import logging import os import random import re import string import subprocess import sys import tempfile from datetime import timedelta from enum import Enum, IntEnum from trace import Direction, PacketType, TraceAnalyzer from typing import List from result import TestResult KiB = 1 << 10 MiB = 1 << 20 GiB = 1 << 30 QUIC_DRAFT = 34 # draft-34 QUIC_VERSION = hex(0x1) class Perspective(Enum): SERVER = "server" CLIENT = "client" class ECN(IntEnum): NONE = 0 ECT1 = 1 ECT0 = 2 CE = 3 def random_string(length: int): """Generate a random string of fixed length """ letters = string.ascii_lowercase return "".join(random.choice(letters) for i in range(length)) def generate_cert_chain(directory: str, length: int = 1): cmd = "./certs.sh " + directory + " " + str(length) r = subprocess.run( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) logging.debug("%s", r.stdout.decode("utf-8")) if r.returncode != 0: logging.info("Unable to create certificates") sys.exit(1) class TestCase(abc.ABC): _files = [] _www_dir = None _client_keylog_file = None _server_keylog_file = None _download_dir = None _sim_log_dir = None _cert_dir = None _cached_server_trace = None _cached_client_trace = None _start_time = None _end_time = None _server_ip = None _server_port = None _server_name = None _link_bandwidth = None _delay = None _packet_reorder: [str] = [] _loss = None _corruption = None def __init__( self, sim_log_dir: tempfile.TemporaryDirectory, client_keylog_file: str, server_keylog_file: str, client_log_dir: str, server_log_dir: str, client_qlog_dir: str, server_qlog_dir: str, link_bandwidth: str, delay: str, packet_reorder: [str], loss: str, corruption: str, server_ip: str = "127.0.0.2", server_name: str = "server", server_port: int = 4433, ): self._server_keylog_file = server_keylog_file self._client_keylog_file = client_keylog_file self._server_log_dir = server_log_dir self._client_log_dir = client_log_dir self._server_qlog_dir = server_qlog_dir self._client_qlog_dir = client_qlog_dir self._files = [] self._sim_log_dir = sim_log_dir self._server_ip = server_ip self._server_port = server_port self._server_name = server_name self._link_bandwidth = link_bandwidth self._delay = delay self._packet_reorder = packet_reorder self._loss = loss self._corruption = corruption @abc.abstractmethod def name(self): pass @abc.abstractmethod def desc(self): pass def __str__(self): return self.name() def testname(self, p: Perspective): """ The name of testcase presented to the endpoint Docker images""" return self.name() @staticmethod def scenario() -> str: """ Scenario for the ns3 simulator """ return "simple-p2p --delay=15ms --bandwidth=10Mbps --queue=25" @staticmethod def timeout() -> int: """ timeout in s """ return 120 @staticmethod def additional_envs() -> List[str]: return [""] @staticmethod def additional_containers() -> List[str]: return [""] @staticmethod def use_tcpdump() ->bool: return True @staticmethod def use_ifstat() -> bool: return False @staticmethod def use_qlog() -> bool: return True def urlprefix(self) -> str: """ URL prefix """ return f"https://{self.servername()}:{self.port()}/" def ip(self): return self._server_ip def port(self): return str(self._server_port) def servername(self): return self._server_name def www_dir(self): if not self._www_dir: self._www_dir = tempfile.TemporaryDirectory(dir="/tmp", prefix="www_") return self._www_dir.name + "/" def download_dir(self): if not self._download_dir: self._download_dir = tempfile.TemporaryDirectory( dir="/tmp", prefix="download_" ) return self._download_dir.name + "/" def certs_dir(self): if not self._cert_dir: self._cert_dir = tempfile.TemporaryDirectory(dir="/tmp", prefix="certs_") generate_cert_chain(self._cert_dir.name) return self._cert_dir.name + "/" def _is_valid_keylog(self, filename) -> bool: if not os.path.isfile(filename) or os.path.getsize(filename) == 0: return False with open(filename, "r") as file: if not re.search( r"^SERVER_HANDSHAKE_TRAFFIC_SECRET", file.read(), re.MULTILINE ): logging.info("Key log file %s is using incorrect format.", filename) return False return True def _keylog_file(self) -> str: if self._is_valid_keylog(self._client_keylog_file): logging.debug("Using the client's key log file.") return self._client_keylog_file elif self._is_valid_keylog(self._server_keylog_file): logging.debug("Using the server's key log file.") return self._server_keylog_file logging.debug("No key log file found.") def is_bandwidth_limited(self) -> bool: return self._link_bandwidth is not None def is_delay_added(self) -> bool: return self._delay is not None def is_packet_reorder_added(self) -> bool: return self._packet_reorder def is_loss_added(self) -> bool: return self._loss is not None def is_corruption_added(self) -> bool: return self._corruption is not None def bandwidth(self) -> str: return self._link_bandwidth def delay(self) -> str: return self._delay def packet_reorder(self) -> [str]: return self._packet_reorder def loss(self) -> str: return self._loss def corruption(self) -> str: return self._corruption def _client_trace(self): if self._cached_client_trace is None: self._cached_client_trace = TraceAnalyzer( self._sim_log_dir.name + "/trace.pcap", self._keylog_file(), ip4_server=self._server_ip, port_server=self._server_port, ) return self._cached_client_trace def _server_trace(self): if self._cached_server_trace is None: self._cached_server_trace = TraceAnalyzer( self._sim_log_dir.name + "/trace.pcap", self._keylog_file(), ip4_server=self._server_ip, port_server=self._server_port, ) return self._cached_server_trace def _generate_random_file(self, size, filename_len=10, host=None) -> str: filename = random_string(filename_len) path = self.www_dir() + filename if host: # testbed mode # https://superuser.com/questions/792427/creating-a-large-file-of-random-bytes-quickly cmd = f'ssh {host} \'touch {path} && shred -n 1 -s {size} {path}\'' logging.debug(cmd) p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) stdout, stderr = p.communicate() else: # See https://realpython.com/python-random/ for byte generation # with urandom random_bytes = os.urandom(size) with open(path, "wb") as f: f.write(random_bytes) logging.debug("Generated random file: %s of size: %d", path, size) return filename def _generate_file(self, content, filename_len=10, host=None) -> str: filename = random_string(filename_len) path = self.www_dir() + filename if host: # testbed mode cmd = f'ssh {host} \'touch {path} && echo "{content}" > {path}\'' logging.debug(cmd) p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) p.communicate() else: with open(path, "w") as f: f.write(content) logging.debug("Generated file: %s with content: %s", path, content) return filename def _retry_sent(self) -> bool: return len(self._client_trace().get_retry()) > 0 def _check_version(self) -> bool: versions = [hex(int(v, 0)) for v in self._get_versions()] if len(versions) != 1: logging.info("Expected exactly one version. Got %s", versions) return False if QUIC_VERSION not in versions: logging.info("Wrong version. Expected %s, got %s", QUIC_VERSION, versions) return False return True def _check_files(self, client=None, server=None) -> bool: if len(self._files) == 0: raise Exception("No test files generated.") if client and server: # testbed mode for file in self._files: cmd = f'ssh {client} \'stat -c %s {self.download_dir() + file}\'' logging.debug(cmd) client_p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) client_stdout, client_stderr = client_p.communicate(timeout=10) cmd = f'ssh {server} \'stat -c %s {self.www_dir() + file}\'' logging.debug(cmd) server_p = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) server_stdout, server_stderr = server_p.communicate(timeout=10) if client_p.returncode > 0 or server_p.returncode > 0: logging.debug(f'An error occured while comparing the filesize!') logging.debug(f'Client stderr: {client_stderr.decode()}') logging.debug(f'Server stderr: {server_stderr.decode()}') return False client_size = float(client_stdout.decode()) server_size = float(server_stdout.decode()) deviation = abs(client_size - server_size) / max(client_size, server_size) if client_size != server_size: logging.debug(f'Different filesize: {client_size} | {server_size}') # We allow differences in the filesize < 1% if deviation < 0.01: logging.debug(f'Different filesize tolerated (less than 1%)') else: logging.debug(f'Different filesize not tolerated: {deviation * 100:.2f}%') return False else: files = [ n for n in os.listdir(self.download_dir()) if os.path.isfile(os.path.join(self.download_dir(), n)) ] too_many = [f for f in files if f not in self._files] if len(too_many) != 0: logging.info("Found unexpected downloaded files: %s", too_many) too_few = [f for f in self._files if f not in files] if len(too_few) != 0: logging.info("Missing files: %s", too_few) if len(too_many) != 0 or len(too_few) != 0: return False for f in self._files: fp = self.download_dir() + f if not os.path.isfile(fp): logging.info("File %s does not exist.", fp) return False try: size = os.path.getsize(self.www_dir() + f) downloaded_size = os.path.getsize(fp) deviation = abs(downloaded_size - size) / max(downloaded_size, size) # We allow differences in the filesize < 1% if deviation < 0.01: logging.debug(f'Different filesize tolerated (less than 1%)') else: logging.debug(f'Different filesize not tolerated: {deviation * 100:.2f}%') logging.debug(f'File size of {fp} doesn\'t match. Original: {size} bytes, downloaded: {downloaded_size} bytes.') return False except Exception as exception: logging.info( "Could not compare files %s and %s: %s", self.www_dir() + f, fp, exception, ) return False logging.debug("Check of downloaded files succeeded.") return True def _check_version_and_files(self) -> bool: if not self._check_version(): return False return self._check_files() def _count_handshakes(self) -> int: """ Count the number of QUIC handshakes """ tr = self._server_trace() # Determine the number of handshakes by looking at Initial packets. # This is easier, since the SCID of Initial packets doesn't changes. return len(set([p.scid for p in tr.get_initial(Direction.FROM_SERVER)])) def _get_versions(self) -> set: """ Get the QUIC versions """ tr = self._server_trace() return set([p.version for p in tr.get_initial(Direction.FROM_SERVER)]) def _payload_size(self, packets: List) -> int: """ Get the sum of the payload sizes of all packets """ size = 0 for p in packets: if hasattr(p, "long_packet_type"): if hasattr(p, "payload"): # when keys are available size += len(p.payload.split(":")) else: size += len(p.remaining_payload.split(":")) else: if hasattr(p, "protected_payload"): size += len(p.protected_payload.split(":")) return size def cleanup(self): if self._www_dir: self._www_dir.cleanup() self._www_dir = None if self._download_dir: self._download_dir.cleanup() self._download_dir = None @abc.abstractmethod def get_paths(self, max_size=None, host=None): pass @abc.abstractmethod def check(self, client=None, server=None) -> TestResult: pass class Measurement(TestCase): REPETITIONS = 10 @abc.abstractmethod def result(self) -> float: pass @staticmethod @abc.abstractmethod def unit() -> str: pass @classmethod def repetitions(cls) -> int: return cls.REPETITIONS @staticmethod def use_tcpdump() ->bool: return False @staticmethod def use_ifstat() -> bool: return True @staticmethod def use_qlog() -> bool: return False class TestCaseHandshake(TestCase): @staticmethod def name(): return "handshake" @staticmethod def abbreviation(): return "H" @staticmethod def desc(): return "Handshake completes successfully." def get_paths(self, max_size=None, host=None): self._files = [self._generate_random_file(1 * KiB)] return self._files def check(self, client=None, server=None) -> TestResult: if not self._check_version_and_files(): return TestResult.FAILED if self._retry_sent(): logging.info("Didn't expect a Retry to be sent.") return TestResult.FAILED num_handshakes = self._count_handshakes() if num_handshakes != 1: logging.info("Expected exactly 1 handshake. Got: %d", num_handshakes) return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseVersionNegotiation(TestCase): @staticmethod def name(): return "versionnegotiation" @staticmethod def abbreviation(): return "V" @staticmethod def desc(): return "A version negotiation packet is elicited and acted on." def get_paths(self, max_size=None, host=None): return [""] def check(self, client=None, server=None) -> TestResult: tr = self._client_trace() initials = tr.get_initial(Direction.FROM_CLIENT) dcid = "" for p in initials: dcid = p.dcid break if dcid == "": logging.info("Didn't find an Initial / a DCID.") return TestResult.FAILED vnps = tr.get_vnp() for p in vnps: if hasattr(p.quic, "scid"): if p.quic.scid == dcid: return TestResult.SUCCEEDED logging.info("Didn't find a Version Negotiation Packet with matching SCID.") return TestResult.FAILED class TestCaseMulti(TestCase): @staticmethod def name(): return "multihandshake" def testname(self, p: Perspective): return "multihandshake" @staticmethod def abbreviation(): return "MHS" @staticmethod def desc(): return "Stream data is being sent and received correctly. Connection close completes with a zero error code." def get_paths(self, max_size=None, host=None): self._files = [ self._generate_random_file(2 * KiB), self._generate_random_file(3 * KiB), self._generate_random_file(5 * KiB), ] return self._files def check(self, client=None, server=None) -> TestResult: num_handshakes = self._count_handshakes() if num_handshakes != 3: logging.info("Expected exactly 3 handshake. Got: %d", num_handshakes) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseTransfer(TestCase): @staticmethod def name(): return "transfer" def testname(self, p: Perspective): return "transfer" @staticmethod def abbreviation(): return "T" @staticmethod def desc(): return "Stream data is being sent and received correctly. Connection close completes with a zero error code." def get_paths(self, max_size=None, host=None): self._files = [ self._generate_random_file(2 * KiB), self._generate_random_file(3 * KiB), self._generate_random_file(5 * KiB), ] return self._files def check(self, client=None, server=None) -> TestResult: num_handshakes = self._count_handshakes() if num_handshakes != 1: logging.info("Expected exactly 1 handshake. Got: %d", num_handshakes) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseFollow(TestCase): @staticmethod def name(): return "follow" def testname(self, p: Perspective): return "follow" @staticmethod def abbreviation(): return "F" @staticmethod def desc(): return "Two files are created but the name of the second file is in the first file. The client only has one REQUEST but should download both files." def get_paths(self, max_size=None, host=None): second_file = self._generate_random_file(5 * KiB, host=host) first_file = self._generate_file(second_file, host=host) self._files = [first_file, second_file] return self._files[:1] def check(self, client=None, server=None) -> TestResult: if not self._check_files(client=client, server=server): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseChaCha20(TestCase): @staticmethod def name(): return "chacha20" @staticmethod def testname(p: Perspective): return "chacha20" @staticmethod def abbreviation(): return "C20" @staticmethod def desc(): return "Handshake completes using ChaCha20." def get_paths(self, max_size=None, host=None): self._files = [self._generate_random_file(3 * KiB)] return self._files def check(self, client=None, server=None) -> TestResult: num_handshakes = self._count_handshakes() if num_handshakes != 1: logging.info("Expected exactly 1 handshake. Got: %d", num_handshakes) return TestResult.FAILED ciphersuites = [] for p in self._client_trace().get_initial(Direction.FROM_CLIENT): if hasattr(p, "tls_handshake_ciphersuite"): ciphersuites.append(p.tls_handshake_ciphersuite) if len(set(ciphersuites)) != 1 or (ciphersuites[0] != "4867" and ciphersuites [0] != "0x1303"): logging.info( "Expected only ChaCha20 cipher suite to be offered. Got: %s", set(ciphersuites), ) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseTransportParameter(TestCase): @staticmethod def name(): return "transportparameter" @staticmethod def testname(p: Perspective): return "transportparameter" @staticmethod def abbreviation(): return "TP" @staticmethod def desc(): return "Hundreds of files are transferred over a single connection, and server increased stream limits to accommodate client requests." def get_paths(self, max_size=None, host=None): for _ in range(1, 5): self._files.append(self._generate_random_file(32)) return self._files def check(self, client=None, server=None) -> TestResult: if not self._keylog_file(): logging.info("Can't check test result. SSLKEYLOG required.") return TestResult.UNSUPPORTED num_handshakes = self._count_handshakes() if num_handshakes != 1: logging.info("Expected exactly 1 handshake. Got: %d", num_handshakes) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED # Check that the server set a bidirectional stream limit <= 1000 checked_stream_limit = False for p in self._client_trace().get_handshake(Direction.FROM_SERVER): if hasattr(p, "tls.quic.parameter.initial_max_streams_bidi"): checked_stream_limit = True stream_limit = int( getattr(p, "tls.quic.parameter.initial_max_streams_bidi") ) logging.debug("Server set bidirectional stream limit: %d", stream_limit) if stream_limit > 10: logging.info("Server set a stream limit > 10.") return TestResult.FAILED if not checked_stream_limit: logging.info("Couldn't check stream limit.") return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseRetry(TestCase): @staticmethod def name(): return "retry" @staticmethod def abbreviation(): return "S" @staticmethod def desc(): return "Server sends a Retry, and a subsequent connection using the Retry token completes successfully." def get_paths(self, max_size=None, host=None): self._files = [ self._generate_random_file(10 * KiB), ] return self._files def _check_trace(self) -> bool: # check that (at least) one Retry packet was actually sent tr = self._client_trace() tokens = [] retries = tr.get_retry(Direction.FROM_SERVER) for p in retries: if not hasattr(p, "retry_token"): logging.info("Retry packet doesn't have a retry_token") logging.info(p) return False tokens += [p.retry_token.replace(":", "")] if len(tokens) == 0: logging.info("Didn't find any Retry packets.") return False # check that an Initial packet uses a token sent in the Retry packet(s) highest_pn_before_retry = -1 for p in tr.get_initial(Direction.FROM_CLIENT): pn = int(p.packet_number) if p.token_length == "0": highest_pn_before_retry = max(highest_pn_before_retry, pn) continue if pn <= highest_pn_before_retry: logging.debug( "Client reset the packet number. Check failed for PN %d", pn ) return False token = p.token.replace(":", "") if token in tokens: logging.debug("Check of Retry succeeded. Token used: %s", token) return True logging.info("Didn't find any Initial packet using a Retry token.") return False def check(self, client=None, server=None) -> TestResult: num_handshakes = self._count_handshakes() if num_handshakes != 1: logging.info("Expected exactly 1 handshake. Got: %d", num_handshakes) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED if not self._check_trace(): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseResumption(TestCase): @staticmethod def name(): return "resumption" @staticmethod def abbreviation(): return "R" @staticmethod def desc(): return "Connection is established using TLS Session Resumption." def get_paths(self, max_size=None, host=None): self._files = [ self._generate_random_file(5 * KiB), self._generate_random_file(10 * KiB), ] return self._files def check(self, client=None, server=None) -> TestResult: if not self._keylog_file(): logging.info("Can't check test result. SSLKEYLOG required.") return TestResult.UNSUPPORTED num_handshakes = self._count_handshakes() if num_handshakes != 2: logging.info("Expected exactly 2 handshake. Got: %d", num_handshakes) return TestResult.FAILED handshake_packets = self._client_trace().get_handshake(Direction.FROM_SERVER) cids = [p.scid for p in handshake_packets] first_handshake_has_cert = False for p in handshake_packets: if p.scid == cids[0]: if hasattr(p, "tls_handshake_certificates_length"): first_handshake_has_cert = True elif p.scid == cids[len(cids) - 1]: # second handshake if hasattr(p, "tls_handshake_certificates_length"): logging.info( "Server sent a Certificate message in the second handshake." ) return TestResult.FAILED else: logging.info( "Found handshake packet that neither belongs to the first nor the second handshake." ) return TestResult.FAILED if not first_handshake_has_cert: logging.info( "Didn't find a Certificate message in the first handshake. That's weird." ) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED return TestResult.SUCCEEDED class TestCaseZeroRTT(TestCase): NUM_FILES = 2 FILESIZE = 10 * KiB # in bytes FILENAMELEN = 10 @staticmethod def name(): return "zerortt" @staticmethod def abbreviation(): return "Z" @staticmethod def desc(): return "0-RTT data is being sent and acted on." def get_paths(self, max_size=None, host=None): for _ in range(self.NUM_FILES): self._files.append( self._generate_random_file(self.FILESIZE, self.FILENAMELEN) ) return self._files def check(self, client=None, server=None) -> TestResult: num_handshakes = self._count_handshakes() if num_handshakes != 2: logging.info("Expected exactly 2 handshakes. Got: %d", num_handshakes) return TestResult.FAILED if not self._check_version_and_files(): return TestResult.FAILED tr = self._client_trace() zeroRTTSize = self._payload_size(tr.get_0rtt()) oneRTTSize = self._payload_size(tr.get_1rtt(Direction.FROM_CLIENT)) logging.debug("0-RTT size: %d", zeroRTTSize) logging.debug("1-RTT size: %d", oneRTTSize) if zeroRTTSize == 0: logging.info("Client didn't send any 0-RTT data.") return TestResult.FAILED return TestResult.SUCCEEDED class MeasurementGoodput(Measurement): FILESIZE = 1 * GiB _result = 0.0 @staticmethod def name(): return "goodput" @staticmethod def timeout(): return 300 @staticmethod def unit() -> str: return "Mbps" @staticmethod def testname(p: Perspective): return "goodput" @staticmethod def abbreviation(): return "G" @staticmethod def desc(): return "Measures connection goodput as baseline." def get_paths(self, max_size=None, host=None): if max_size and max_size < self.FILESIZE: logging.debug(f'Limit filesize for {self.name()} to {max_size}') self.FILESIZE = max_size self._files = [ self._generate_random_file( self.FILESIZE, host=host ) ] return self._files def check(self, client=None, server=None) -> TestResult: if not self._check_files(client=client, server=server): return TestResult.FAILED time = (self._end_time - self._start_time) / timedelta(seconds=1) goodput = (8 * self.FILESIZE) / time / 10**6 logging.info( f"Transferring {self.FILESIZE / 10**6:.2f} MB took {time:.3f} s. Goodput: {goodput:.3f} {self.unit()}", ) self._result = goodput return TestResult.SUCCEEDED def result(self) -> float: return self._result class MeasurementQlog(Measurement): FILESIZE = 200 * MiB _result = 0.0 @staticmethod def name(): return "qlog" @staticmethod def timeout(): return 80 @staticmethod def unit() -> str: return "Mbps" @staticmethod def testname(p: Perspective): return "qlog" @staticmethod def abbreviation(): return "Q" @staticmethod def desc(): return "Measures connection goodput while running qlog." @staticmethod def use_qlog() -> bool: return True def get_paths(self, max_size=None, host=None): self._files = [self._generate_random_file(min(self.FILESIZE, max_size) if max_size else self.FILESIZE )] return self._files def check(self, client=None, server=None) -> TestResult: result_status = TestResult.SUCCEEDED # Check if qlog file exists client_qlogs = [os.path.join(self._client_qlog_dir, name) for name in os.listdir(self._client_qlog_dir)] server_qlogs = [os.path.join(self._server_qlog_dir, name) for name in os.listdir(self._server_qlog_dir)] if len(client_qlogs) < 1: logging.info(f"Expected at least 1 qlog file from client. Got: {len(client_qlogs)}") result_status = TestResult.FAILED if len(server_qlogs) < 1: logging.info(f"Expected at least 1 qlog file from server. Got: {len(server_qlogs)}") result_status = TestResult.FAILED logging.debug(f"Deleting {len(client_qlogs + server_qlogs)} qlogs") for f in client_qlogs + server_qlogs: os.remove(f) if not self._check_files(): result_status = TestResult.FAILED if result_status == TestResult.FAILED: return result_status time = (self._end_time - self._start_time) / timedelta(seconds=1) goodput = (8 * self.FILESIZE) / time / 10**6 logging.info( f"Transferring {self.FILESIZE / 10**6:.2f} MB took {time:.3f} s. Goodput (with qlog): {goodput:.3f} {self.unit()}", ) self._result = goodput return TestResult.SUCCEEDED def result(self) -> float: return self._result class MeasurementOptimize(MeasurementGoodput): @staticmethod def name(): return "optimize" @staticmethod def timeout(): return 80 @staticmethod def testname(p: Perspective): return "optimize" @staticmethod def abbreviation(): return "Opt" @staticmethod def desc(): return "Measures connection goodput with optimizations." TESTCASES = [ TestCaseHandshake, TestCaseTransfer, TestCaseMulti, TestCaseChaCha20, TestCaseTransportParameter, TestCaseRetry, TestCaseResumption, TestCaseZeroRTT, TestCaseFollow ] MEASUREMENTS = [ MeasurementGoodput, MeasurementQlog, MeasurementOptimize, ]