diff --git a/httpcore/_async/connection.py b/httpcore/_async/connection.py index b42581df..6e4db2a3 100644 --- a/httpcore/_async/connection.py +++ b/httpcore/_async/connection.py @@ -114,7 +114,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), + "host": self._origin.host.decode("ascii").rstrip("."), "port": self._origin.port, "local_address": self._local_address, "timeout": timeout, @@ -149,7 +149,7 @@ async def _connect(self, request: Request) -> AsyncNetworkStream: kwargs = { "ssl_context": ssl_context, "server_hostname": sni_hostname - or self._origin.host.decode("ascii"), + or self._origin.host.decode("ascii").rstrip("."), "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/httpcore/_async/http_proxy.py b/httpcore/_async/http_proxy.py index cc9d9206..6e2c2e9e 100644 --- a/httpcore/_async/http_proxy.py +++ b/httpcore/_async/http_proxy.py @@ -309,7 +309,7 @@ async def handle_async_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": self._remote_origin.host.decode("ascii").rstrip("."), "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/httpcore/_async/socks_proxy.py b/httpcore/_async/socks_proxy.py index b363f55a..8a6eca1e 100644 --- a/httpcore/_async/socks_proxy.py +++ b/httpcore/_async/socks_proxy.py @@ -223,7 +223,7 @@ async def handle_async_request(self, request: Request) -> Response: try: # Connect to the proxy kwargs = { - "host": self._proxy_origin.host.decode("ascii"), + "host": self._proxy_origin.host.decode("ascii").rstrip("."), "port": self._proxy_origin.port, "timeout": timeout, } @@ -234,7 +234,7 @@ async def handle_async_request(self, request: Request) -> Response: # Connect to the remote host using socks5 kwargs = { "stream": stream, - "host": self._remote_origin.host.decode("ascii"), + "host": self._remote_origin.host.decode("ascii").rstrip("."), "port": self._remote_origin.port, "auth": self._proxy_auth, } @@ -259,7 +259,7 @@ async def handle_async_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, "server_hostname": sni_hostname - or self._remote_origin.host.decode("ascii"), + or self._remote_origin.host.decode("ascii").rstrip("."), "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/httpcore/_backends/sync.py b/httpcore/_backends/sync.py index 4018a09c..8f1d20f3 100644 --- a/httpcore/_backends/sync.py +++ b/httpcore/_backends/sync.py @@ -88,9 +88,13 @@ def write(self, buffer: bytes, timeout: float | None = None) -> None: exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError} with map_exceptions(exc_map): self._sock.settimeout(timeout) - while buffer: - nsent = self._perform_io(functools.partial(self.ssl_obj.write, buffer)) - buffer = buffer[nsent:] + # Use a memoryview to avoid O(n²) copying when buffer is sliced on each + # iteration. Plain bytes slicing (buffer = buffer[n:]) creates a full copy; + # memoryview slicing is zero-copy, critical for large upload payloads. + view = memoryview(buffer) + while view: + nsent = self._perform_io(functools.partial(self.ssl_obj.write, view)) + view = view[nsent:] def close(self) -> None: self._sock.close() @@ -133,10 +137,14 @@ def write(self, buffer: bytes, timeout: float | None = None) -> None: exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError} with map_exceptions(exc_map): - while buffer: + # Use a memoryview to avoid O(n²) copying when buffer is sliced on each + # iteration. Plain bytes slicing (buffer = buffer[n:]) creates a full copy; + # memoryview slicing is zero-copy, critical for large upload payloads. + view = memoryview(buffer) + while view: self._sock.settimeout(timeout) - n = self._sock.send(buffer) - buffer = buffer[n:] + n = self._sock.send(view) + view = view[n:] def close(self) -> None: self._sock.close() diff --git a/httpcore/_models.py b/httpcore/_models.py index 8a65f133..1d467954 100644 --- a/httpcore/_models.py +++ b/httpcore/_models.py @@ -174,7 +174,7 @@ def __eq__(self, other: typing.Any) -> bool: def __str__(self) -> str: scheme = self.scheme.decode("ascii") - host = self.host.decode("ascii") + host = self.host.decode("ascii").rstrip(".") port = str(self.port) return f"{scheme}://{host}:{port}" diff --git a/httpcore/_sync/connection.py b/httpcore/_sync/connection.py index 363f8be8..da5fc674 100644 --- a/httpcore/_sync/connection.py +++ b/httpcore/_sync/connection.py @@ -114,7 +114,7 @@ def _connect(self, request: Request) -> NetworkStream: try: if self._uds is None: kwargs = { - "host": self._origin.host.decode("ascii"), + "host": self._origin.host.decode("ascii").rstrip("."), "port": self._origin.port, "local_address": self._local_address, "timeout": timeout, @@ -149,7 +149,7 @@ def _connect(self, request: Request) -> NetworkStream: kwargs = { "ssl_context": ssl_context, "server_hostname": sni_hostname - or self._origin.host.decode("ascii"), + or self._origin.host.decode("ascii").rstrip("."), "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/httpcore/_sync/http_proxy.py b/httpcore/_sync/http_proxy.py index ecca88f7..924f4c58 100644 --- a/httpcore/_sync/http_proxy.py +++ b/httpcore/_sync/http_proxy.py @@ -309,7 +309,7 @@ def handle_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": self._remote_origin.host.decode("ascii").rstrip("."), "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/httpcore/_sync/socks_proxy.py b/httpcore/_sync/socks_proxy.py index 0ca96ddf..30c0d93f 100644 --- a/httpcore/_sync/socks_proxy.py +++ b/httpcore/_sync/socks_proxy.py @@ -223,7 +223,7 @@ def handle_request(self, request: Request) -> Response: try: # Connect to the proxy kwargs = { - "host": self._proxy_origin.host.decode("ascii"), + "host": self._proxy_origin.host.decode("ascii").rstrip("."), "port": self._proxy_origin.port, "timeout": timeout, } @@ -234,7 +234,7 @@ def handle_request(self, request: Request) -> Response: # Connect to the remote host using socks5 kwargs = { "stream": stream, - "host": self._remote_origin.host.decode("ascii"), + "host": self._remote_origin.host.decode("ascii").rstrip("."), "port": self._remote_origin.port, "auth": self._proxy_auth, } @@ -259,7 +259,7 @@ def handle_request(self, request: Request) -> Response: kwargs = { "ssl_context": ssl_context, "server_hostname": sni_hostname - or self._remote_origin.host.decode("ascii"), + or self._remote_origin.host.decode("ascii").rstrip("."), "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff --git a/tests/test_trailing_dot.py b/tests/test_trailing_dot.py new file mode 100644 index 00000000..edcf04be --- /dev/null +++ b/tests/test_trailing_dot.py @@ -0,0 +1,30 @@ +"""Tests for trailing-dot FQDN hostname normalisation (issue #1063).""" + +import pytest +import httpcore + + +def test_origin_str_strips_trailing_dot(): + """Origin.__str__ must strip the trailing dot from FQDNs. + + 'myhost.internal.' is a valid FQDN but TLS certificates use + 'myhost.internal' (without the dot). Passing the raw hostname + to ssl_wrap_socket would cause CERTIFICATE_VERIFY_FAILED. + """ + origin = httpcore.Origin(b"https", b"myhost.internal.", 443) + assert str(origin) == "https://myhost.internal:443" + + +def test_origin_str_no_trailing_dot_unchanged(): + """Normal hostnames (no trailing dot) must not be modified.""" + origin = httpcore.Origin(b"https", b"example.com", 443) + assert str(origin) == "https://example.com:443" + + +def test_url_host_strips_trailing_dot(): + """URL.host used for SNI should not carry the trailing dot.""" + url = httpcore.URL("https://myhost.internal.:8443/") + assert url.host == b"myhost.internal." # raw host preserved + # but str(origin) strips it for TLS + origin = httpcore.Origin(b"https", url.host, url.port) + assert str(origin) == "https://myhost.internal:8443" diff --git a/tests/test_write_performance.py b/tests/test_write_performance.py new file mode 100644 index 00000000..99d0065d --- /dev/null +++ b/tests/test_write_performance.py @@ -0,0 +1,78 @@ +"""Tests for zero-copy write optimisation using memoryview (issue #1029).""" + +import pytest +import tracemalloc + + +def _simulate_write_bytes(data: bytes, chunk_size: int = 65536) -> int: + """Simulate the OLD write loop (bytes slicing — copies on each iteration).""" + copies = 0 + buf = data + while buf: + _chunk = buf[:chunk_size] # consumed by socket.send() in production + buf = buf[chunk_size:] + copies += 1 + return copies + + +def _simulate_write_memoryview(data: bytes, chunk_size: int = 65536) -> int: + """Simulate the NEW write loop (memoryview — zero-copy slicing).""" + copies = 0 + view = memoryview(data) + while view: + _chunk = view[:chunk_size] # consumed by socket.send() in production + view = view[chunk_size:] + copies += 1 + return copies + + +def test_memoryview_slicing_zero_copy(): + """memoryview slicing must not allocate new bytes objects. + + bytes slicing (buffer = buffer[n:]) copies the remaining bytes on every + iteration — O(n²) total allocation for a large payload. + memoryview slicing is zero-copy and runs in O(n) memory. + """ + data = b"x" * (4 * 1024 * 1024) # 4 MB + + tracemalloc.start() + snap_before = tracemalloc.take_snapshot() + _ = _simulate_write_memoryview(data) + snap_after = tracemalloc.take_snapshot() + tracemalloc.stop() + + # Measure net allocation delta + stats = snap_after.compare_to(snap_before, "lineno") + allocated = sum(s.size_diff for s in stats if s.size_diff > 0) + + # memoryview approach should allocate essentially nothing (< 1 MB overhead) + assert allocated < 1024 * 1024, ( + f"memoryview write allocated {allocated / 1024:.0f} KB — " + "expected near-zero (zero-copy), got unexpected allocation" + ) + + +def test_write_loop_correct_iteration_count(): + """Both loops must iterate the same number of times for the same payload.""" + chunk = 65536 + for size in [1024, chunk, chunk * 5, chunk * 100]: + data = b"0" * size + n_bytes = _simulate_write_bytes(data, chunk) + n_mv = _simulate_write_memoryview(data, chunk) + assert n_bytes == n_mv, ( + f"size={size}: bytes loop={n_bytes} iters, " + f"memoryview loop={n_mv} iters — must be equal" + ) + + +def test_write_loop_handles_empty_buffer(): + """Empty payload should result in zero iterations (no write attempted).""" + assert _simulate_write_bytes(b"", 65536) == 0 + assert _simulate_write_memoryview(b"", 65536) == 0 + + +def test_write_loop_handles_sub_chunk_payload(): + """Payload smaller than one chunk should result in exactly one iteration.""" + data = b"x" * 1000 + assert _simulate_write_bytes(data, 65536) == 1 + assert _simulate_write_memoryview(data, 65536) == 1