From 0bfc146fdfb47c81b193d3c4bb8c848982d3bc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 21 Jul 2026 14:46:48 +0200 Subject: [PATCH] Return a tunnel timeout error when the CONNECT deadline elapses receive_response/3 recomputes the remaining timeout from the deadline on every loop iteration and feeds it straight into the receive's after clause. A proxy that keeps dribbling bytes of the CONNECT response until the deadline passes mid-loop produces a negative timeout, which raises ErlangError :timeout_value in the calling process instead of returning {:error, %Mint.HTTPError{reason: {:proxy, :tunnel_timeout}}}. Clamp the timeout to 0 so an elapsed deadline yields the documented error. Also document the :tunnel_timeout option in the Proxying section; it was previously only mentioned in the Mint.HTTPError docs. --- lib/mint/http.ex | 9 ++++++ lib/mint/tunnel_proxy.ex | 2 +- test/mint/tunnel_proxy_connect_test.exs | 37 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/mint/http.ex b/lib/mint/http.ex index 05ee649c..45d65f78 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -287,6 +287,15 @@ defmodule Mint.HTTP do uses TLS and the TLS session to the host is nested inside it. *HTTPS proxies for HTTPS connections are available since v1.10.0*. + The `opts` in the `:proxy` tuple are the options of the connection to the proxy + itself and support the same options as `connect/4`. Tunnel proxies additionally + support: + + * `:tunnel_timeout` - the maximum time (in milliseconds) to wait for the proxy + to reply to the `CONNECT` request. If the tunnel is not established within + this time, `connect/4` returns a `Mint.HTTPError` with reason + `{:proxy, :tunnel_timeout}`. Defaults to `30_000` (30 seconds). + ## Transport options The options specified in `:transport_opts` are passed to the module that diff --git a/lib/mint/tunnel_proxy.ex b/lib/mint/tunnel_proxy.ex index f2e3c012..55b95fb8 100644 --- a/lib/mint/tunnel_proxy.ex +++ b/lib/mint/tunnel_proxy.ex @@ -56,7 +56,7 @@ defmodule Mint.TunnelProxy do end defp receive_response(conn, ref, timeout_deadline) do - timeout = timeout_deadline - System.monotonic_time(:millisecond) + timeout = max(timeout_deadline - System.monotonic_time(:millisecond), 0) socket = HTTP1.get_socket(conn) receive do diff --git a/test/mint/tunnel_proxy_connect_test.exs b/test/mint/tunnel_proxy_connect_test.exs index 1cb68f04..74d93aa2 100644 --- a/test/mint/tunnel_proxy_connect_test.exs +++ b/test/mint/tunnel_proxy_connect_test.exs @@ -37,6 +37,24 @@ defmodule Mint.TunnelProxyConnectTest do assert merge_body(rest, request) == "hello" end + test "a proxy that stalls the CONNECT response yields a tunnel timeout error" do + proxy_port = start_silent_proxy() + + assert {:error, %Mint.HTTPError{reason: {:proxy, :tunnel_timeout}}} = + HTTP.connect(:https, "localhost", 443, + proxy: {:http, "localhost", proxy_port, [tunnel_timeout: 50]} + ) + end + + test "an already-elapsed tunnel deadline yields a tunnel timeout error instead of crashing" do + proxy_port = start_silent_proxy() + + assert {:error, %Mint.HTTPError{reason: {:proxy, :tunnel_timeout}}} = + HTTP.connect(:https, "localhost", 443, + proxy: {:http, "localhost", proxy_port, [tunnel_timeout: -1]} + ) + end + test "IPv6 address targets produce a bracketed CONNECT authority" do {proxy_port, proxy_ref} = start_capturing_proxy() @@ -82,6 +100,25 @@ defmodule Mint.TunnelProxyConnectTest do {port, ref} end + # Starts a one-shot proxy that accepts a single connection and never + # responds. + defp start_silent_proxy do + {:ok, listen_socket} = + :gen_tcp.listen(0, mode: :binary, packet: :raw, active: false, reuseaddr: true) + + {:ok, port} = :inet.port(listen_socket) + + spawn_link(fn -> + {:ok, socket} = :gen_tcp.accept(listen_socket) + + receive do + :stop -> :gen_tcp.close(socket) + end + end) + + port + end + # Starts a one-shot server that accepts a single connection, reports the raw # request head to the test process, and closes the connection. defp start_capturing_proxy do