From 36d30ca9658c88a5a0a953a450e38229dda3faa1 Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:48:26 +0300 Subject: [PATCH 1/6] feat: enable TCP keepalive from the keepalive pool option The keepalive pool option was accepted and silently ignored. It now enables SO_KEEPALIVE on the connection socket, with the option value (milliseconds) as the idle time before the first probe, a 5 second probe interval and 3 probes. On Linux and macOS the idle time, interval and count are raw socket options, because the native keepidle option only exists since OTP 28.3 and gen_tcp rejects it on macOS. The idle time is rounded down to whole seconds and kept between 1 and 32767 seconds, the Linux limit. Other systems get SO_KEEPALIVE with the OS defaults. {keepalive, false} in transport_opts disables it. Any other keepalive option there, native or as a raw IPPROTO_TCP option, replaces only the matching one. keepcnt, keepidle and keepintvl in transport_opts now go to gen_tcp. They used to be dropped for tcp and passed to the TLS options for tls. --- changelog.md | 12 ++++ src/ehttpc.erl | 135 +++++++++++++++++++++++++++++++++++++++++- test/ehttpc_tests.erl | 31 ++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 3acbc13..4142d65 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # ehttpc changes +## Unreleased + +- Added TCP keepalive for the `keepalive` pool option, which used to be ignored. On Linux + and macOS, probes start after `keepalive` milliseconds of idle time, rounded down to whole + seconds and kept between 1 and 32767 seconds. They are sent every 5 seconds, and the + connection is closed after 3 unanswered probes. Other systems only enable `SO_KEEPALIVE` + and use the OS timers. `{keepalive, false}` in `transport_opts` turns it off, and any + other keepalive option set there replaces the matching one. +- Fixed `keepidle`, `keepintvl` and `keepcnt` (OTP 28 and later) in `transport_opts` being + dropped for `tcp` and passed to the TLS options for `tls`. They now go to `gen_tcp`. + `keepidle` is Linux only, and on macOS a connection that sets it fails. + ## 0.7.6 - Zombie-connection detection no longer waits for the full request timeout. A connection diff --git a/src/ehttpc.erl b/src/ehttpc.erl index 6ea35c9..0f6866c 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -491,7 +491,7 @@ gun_opts(Opts) -> %% retry at lower level will likely cause %% gen_server callers to time out anyway GunNoRetry = 0, - gun_opts(Opts, #{ + GunOpts = gun_opts(Opts, #{ retry => GunNoRetry, connect_timeout => 5000, %% The keepalive mechanism of gun will send "\r\n" for keepalive, @@ -500,7 +500,67 @@ gun_opts(Opts) -> protocols => [http], %% Link with client process directly. supervise => false - }). + }), + with_tcp_keepalive(proplists:get_value(keepalive, Opts, infinity), GunOpts). + +%% The `keepalive' option is the idle time in milliseconds before TCP +%% keepalive probes start. `{keepalive, false}' in `transport_opts' turns +%% it off. Any other keepalive option set in `transport_opts' replaces the +%% one the `keepalive' option would set. +with_tcp_keepalive(Timeout, GunOpts) when is_integer(Timeout), Timeout > 0 -> + %% gun's default when `tcp_opts' is not set + TCPOpts = maps:get(tcp_opts, GunOpts, [{send_timeout, 15000}, {send_timeout_close, true}]), + case lists:member({keepalive, false}, TCPOpts) of + true -> + GunOpts; + false -> + KeepaliveOpts = [ + Opt + || {Name, Opt} <- tcp_keepalive_opts(os:type(), Timeout), + not is_tcp_opt_set(Name, Opt, TCPOpts) + ], + GunOpts#{tcp_opts => KeepaliveOpts ++ TCPOpts} + end; +with_tcp_keepalive(_Timeout, GunOpts) -> + GunOpts. + +%% Each option is paired with the name of the native option that sets the +%% same value. Probes are sent every 5 seconds, the connection is dropped +%% after 3 unanswered probes. Raw options are used because the native +%% `keepidle' option is only available on Linux, and only since OTP 28. +%% Linux rejects an idle time above 32767 seconds, and a rejected raw +%% option is silently ignored. +tcp_keepalive_opts(OS, Timeout) -> + Idle = min(32767, max(1, Timeout div 1000)), + Interval = 5, + Count = 3, + case OS of + {unix, linux} -> + [ + {keepalive, {keepalive, true}}, + {keepidle, {raw, 6, 4, <>}}, + {keepintvl, {raw, 6, 5, <>}}, + {keepcnt, {raw, 6, 6, <>}} + ]; + {unix, darwin} -> + [ + {keepalive, {keepalive, true}}, + {keepidle, {raw, 6, 16#10, <>}}, + {keepintvl, {raw, 6, 16#101, <>}}, + {keepcnt, {raw, 6, 16#102, <>}} + ]; + _ -> + [{keepalive, {keepalive, true}}] + end. + +is_tcp_opt_set(Name, {raw, Level, Num, _}, TCPOpts) -> + lists:keymember(Name, 1, TCPOpts) orelse + lists:any(fun(O) -> is_raw_opt(Level, Num, O) end, TCPOpts); +is_tcp_opt_set(Name, _Opt, TCPOpts) -> + lists:keymember(Name, 1, TCPOpts). + +is_raw_opt(Level, Num, {raw, Level, Num, _}) -> true; +is_raw_opt(_Level, _Num, _Opt) -> false. gun_opts([], Acc) -> Acc; @@ -561,6 +621,10 @@ is_gen_tcp_option({header, _}) -> ignore; is_gen_tcp_option({high_msgq_watermark, _}) -> true; is_gen_tcp_option({high_watermark, _}) -> true; is_gen_tcp_option({keepalive, _}) -> true; +%% OTP 28 and later, keepidle is Linux only +is_gen_tcp_option({keepcnt, _}) -> true; +is_gen_tcp_option({keepidle, _}) -> true; +is_gen_tcp_option({keepintvl, _}) -> true; is_gen_tcp_option({linger, _}) -> true; is_gen_tcp_option({low_msgq_watermark, _}) -> true; is_gen_tcp_option({low_watermark, _}) -> true; @@ -1288,4 +1352,71 @@ prioritise_oldest_test() -> ?assertMatch({{value, 1}, _}, OutOldest(Q)), ?assertMatch({{value, 1}, _}, queue:out(Q)). +tcp_keepalive_gun_opts_test_() -> + KeepaliveOpts = expected_tcp_keepalive_opts(os:type(), 30), + %% The raw option that sets the idle time, on systems that have one + IdleRaw = [O || {raw, _, N, _} = O <- KeepaliveOpts, N =:= 4 orelse N =:= 16#10], + CallerIdleRaw = [{raw, L, N, <<60:32/native>>} || {raw, L, N, _} <- IdleRaw], + TCP = [{transport, tcp}, {transport_opts, [{nodelay, true}]}], + TLS = [{transport, tls}, {transport_opts, [{nodelay, true}, {verify, verify_none}]}], + TCPOpts = fun(Opts) -> maps:get(tcp_opts, gun_opts(Opts)) end, + WithTCPOpts = fun(Opts) -> + TCPOpts([{keepalive, 30_000}, {transport, tcp}, {transport_opts, Opts}]) + end, + [ + ?_assertEqual([{nodelay, true}], TCPOpts(TCP)), + ?_assertEqual([{nodelay, true}], TCPOpts([{keepalive, infinity} | TCP])), + ?_assertEqual(KeepaliveOpts ++ [{nodelay, true}], TCPOpts([{keepalive, 30_000} | TCP])), + ?_assertEqual( + #{tcp_opts => KeepaliveOpts ++ [{nodelay, true}], tls_opts => [{verify, verify_none}]}, + maps:with([tcp_opts, tls_opts], gun_opts([{keepalive, 30_000} | TLS])) + ), + ?_assertEqual( + KeepaliveOpts ++ [{send_timeout, 15000}, {send_timeout_close, true}], + TCPOpts([{keepalive, 30_000}]) + ), + ?_assertEqual( + expected_tcp_keepalive_opts(os:type(), 1) ++ [{nodelay, true}], + TCPOpts([{keepalive, 500} | TCP]) + ), + ?_assertEqual([{keepalive, false}], WithTCPOpts([{keepalive, false}])), + ?_assertEqual( + tl(KeepaliveOpts) ++ [{keepalive, true}], + WithTCPOpts([{keepalive, true}]) + ), + ?_assertEqual( + (KeepaliveOpts -- IdleRaw) ++ [{keepidle, 60}], + WithTCPOpts([{keepidle, 60}]) + ), + ?_assertEqual( + (KeepaliveOpts -- IdleRaw) ++ CallerIdleRaw, + WithTCPOpts(CallerIdleRaw) + ) + ]. + +tcp_keepalive_opts_test_() -> + Opts = fun(OS, Timeout) -> [O || {_Name, O} <- tcp_keepalive_opts(OS, Timeout)] end, + [ + ?_assertEqual(expected_tcp_keepalive_opts(OS, Idle), Opts(OS, Timeout)) + || OS <- [{unix, linux}, {unix, darwin}, {unix, freebsd}, {win32, nt}], + {Timeout, Idle} <- [{30_000, 30}, {40_000_000, 32767}] + ]. + +expected_tcp_keepalive_opts({unix, linux}, Idle) -> + [ + {keepalive, true}, + {raw, 6, 4, <>}, + {raw, 6, 5, <<5:32/native>>}, + {raw, 6, 6, <<3:32/native>>} + ]; +expected_tcp_keepalive_opts({unix, darwin}, Idle) -> + [ + {keepalive, true}, + {raw, 6, 16#10, <>}, + {raw, 6, 16#101, <<5:32/native>>}, + {raw, 6, 16#102, <<3:32/native>>} + ]; +expected_tcp_keepalive_opts(_OS, _Idle) -> + [{keepalive, true}]. + -endif. diff --git a/test/ehttpc_tests.erl b/test/ehttpc_tests.erl index 4cf7755..a184576 100644 --- a/test/ehttpc_tests.erl +++ b/test/ehttpc_tests.erl @@ -890,6 +890,37 @@ hash_pool_test() -> end, ?WITH(ServerOpts1, PoolOpts1, Send()). +tcp_keepalive_test_() -> + Port = ?PORT, + ServerOpts = #{port => Port, name => ?FUNCTION_NAME, delay => 0}, + PoolOpts = [{keepalive, 30_000} | pool_opts(Port, false)], + TCPPoolOpts = [{transport, tcp}, {transport_opts, [{nodelay, true}]} | PoolOpts], + Check = fun() -> + ?assertMatch({ok, 200, _, _}, ehttpc:request(?POOL, get, req(), 1000, 0)), + {_, #{client := Client}} = ehttpc:get_state(?POOL), + #{socket := Socket} = gun:info(Client), + ?assertEqual({ok, [{keepalive, true}]}, inet:getopts(Socket, [keepalive])), + assert_tcp_keepalive_timers(Socket, os:type()) + end, + [ + {"no transport_opts", fun() -> ?WITH(ServerOpts, PoolOpts, Check()) end}, + {"tcp transport_opts", fun() -> ?WITH(ServerOpts, TCPPoolOpts, Check()) end} + ]. + +%% Idle time, probe interval and probe count, in this order. +assert_tcp_keepalive_timers(Socket, {unix, linux}) -> + assert_raw_tcp_opts(Socket, [{4, 30}, {5, 5}, {6, 3}]); +assert_tcp_keepalive_timers(Socket, {unix, darwin}) -> + assert_raw_tcp_opts(Socket, [{16#10, 30}, {16#101, 5}, {16#102, 3}]); +assert_tcp_keepalive_timers(_Socket, _OS) -> + ok. + +assert_raw_tcp_opts(Socket, Expected) -> + ?assertEqual( + {ok, [{raw, 6, Opt, <>} || {Opt, Value} <- Expected]}, + inet:getopts(Socket, [{raw, 6, Opt, 4} || {Opt, _} <- Expected]) + ). + with_server(Port, Name, Delay, F) -> ehttpc_test_lib:with_server(Port, Name, Delay, F). From d159d42fc23d94e6a976f02f16bbdc07760e7d29 Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:43:28 +0300 Subject: [PATCH 2/6] chore: bump app vsn to 0.7.7 --- changelog.md | 2 +- src/ehttpc.app.src | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 4142d65..edd539c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,6 @@ # ehttpc changes -## Unreleased +## 0.7.7 - Added TCP keepalive for the `keepalive` pool option, which used to be ignored. On Linux and macOS, probes start after `keepalive` milliseconds of idle time, rounded down to whole diff --git a/src/ehttpc.app.src b/src/ehttpc.app.src index 6bbce1a..baf43e0 100644 --- a/src/ehttpc.app.src +++ b/src/ehttpc.app.src @@ -1,6 +1,6 @@ {application, ehttpc, [ {description, "HTTP Client for Erlang/OTP"}, - {vsn, "0.7.6"}, + {vsn, "0.7.7"}, {registered, []}, {applications, [ kernel, From f1b83b84fbf47d7847c5badc38d39edb85b9f633 Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:44:10 +0300 Subject: [PATCH 3/6] docs: say OTP 28.3 and explain the copied gun send timeouts The native keepcnt, keepidle and keepintvl options first appear in OTP 28.3. gen_tcp raises badarg for them on earlier releases, so a connection that sets them in transport_opts fails there. --- changelog.md | 7 ++++--- src/ehttpc.erl | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/changelog.md b/changelog.md index edd539c..aa40bf4 100644 --- a/changelog.md +++ b/changelog.md @@ -8,9 +8,10 @@ connection is closed after 3 unanswered probes. Other systems only enable `SO_KEEPALIVE` and use the OS timers. `{keepalive, false}` in `transport_opts` turns it off, and any other keepalive option set there replaces the matching one. -- Fixed `keepidle`, `keepintvl` and `keepcnt` (OTP 28 and later) in `transport_opts` being - dropped for `tcp` and passed to the TLS options for `tls`. They now go to `gen_tcp`. - `keepidle` is Linux only, and on macOS a connection that sets it fails. +- Fixed `keepidle`, `keepintvl` and `keepcnt` (OTP 28.3 and later) in `transport_opts` + being dropped for `tcp` and passed to the TLS options for `tls`. They now go to + `gen_tcp`. A connection that sets them fails before OTP 28.3, and one that sets + `keepidle` fails on macOS. ## 0.7.6 diff --git a/src/ehttpc.erl b/src/ehttpc.erl index 0f6866c..311ad1d 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -508,7 +508,8 @@ gun_opts(Opts) -> %% it off. Any other keepalive option set in `transport_opts' replaces the %% one the `keepalive' option would set. with_tcp_keepalive(Timeout, GunOpts) when is_integer(Timeout), Timeout > 0 -> - %% gun's default when `tcp_opts' is not set + %% gun uses these send timeouts only when `tcp_opts' is not set, and the + %% keepalive options are added to `tcp_opts'. TCPOpts = maps:get(tcp_opts, GunOpts, [{send_timeout, 15000}, {send_timeout_close, true}]), case lists:member({keepalive, false}, TCPOpts) of true -> @@ -526,8 +527,8 @@ with_tcp_keepalive(_Timeout, GunOpts) -> %% Each option is paired with the name of the native option that sets the %% same value. Probes are sent every 5 seconds, the connection is dropped -%% after 3 unanswered probes. Raw options are used because the native -%% `keepidle' option is only available on Linux, and only since OTP 28. +%% after 3 unanswered probes. Raw options are used because the native ones +%% only exist since OTP 28.3, and native `keepidle' is Linux only. %% Linux rejects an idle time above 32767 seconds, and a rejected raw %% option is silently ignored. tcp_keepalive_opts(OS, Timeout) -> @@ -621,7 +622,7 @@ is_gen_tcp_option({header, _}) -> ignore; is_gen_tcp_option({high_msgq_watermark, _}) -> true; is_gen_tcp_option({high_watermark, _}) -> true; is_gen_tcp_option({keepalive, _}) -> true; -%% OTP 28 and later, keepidle is Linux only +%% OTP 28.3 and later, keepidle is Linux only is_gen_tcp_option({keepcnt, _}) -> true; is_gen_tcp_option({keepidle, _}) -> true; is_gen_tcp_option({keepintvl, _}) -> true; From 8eb0102f4ce2b60e72f8e381359f91f68fed1537 Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:44:54 +0300 Subject: [PATCH 4/6] fix: cap the keepalive probe interval at the idle time With a keepalive below 5 seconds the probe interval was longer than the idle time before the first probe. --- changelog.md | 9 +++++---- src/ehttpc.erl | 32 +++++++++++++++++--------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/changelog.md b/changelog.md index aa40bf4..e113d35 100644 --- a/changelog.md +++ b/changelog.md @@ -4,10 +4,11 @@ - Added TCP keepalive for the `keepalive` pool option, which used to be ignored. On Linux and macOS, probes start after `keepalive` milliseconds of idle time, rounded down to whole - seconds and kept between 1 and 32767 seconds. They are sent every 5 seconds, and the - connection is closed after 3 unanswered probes. Other systems only enable `SO_KEEPALIVE` - and use the OS timers. `{keepalive, false}` in `transport_opts` turns it off, and any - other keepalive option set there replaces the matching one. + seconds and kept between 1 and 32767 seconds. They are sent every 5 seconds, or at the + idle time if that is shorter, and the connection is closed after 3 unanswered probes. + Other systems only enable `SO_KEEPALIVE` and use the OS timers. `{keepalive, false}` in + `transport_opts` turns it off, and any other keepalive option set there replaces the + matching one. - Fixed `keepidle`, `keepintvl` and `keepcnt` (OTP 28.3 and later) in `transport_opts` being dropped for `tcp` and passed to the TLS options for `tls`. They now go to `gen_tcp`. A connection that sets them fails before OTP 28.3, and one that sets diff --git a/src/ehttpc.erl b/src/ehttpc.erl index 311ad1d..d2170ff 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -526,14 +526,14 @@ with_tcp_keepalive(_Timeout, GunOpts) -> GunOpts. %% Each option is paired with the name of the native option that sets the -%% same value. Probes are sent every 5 seconds, the connection is dropped -%% after 3 unanswered probes. Raw options are used because the native ones -%% only exist since OTP 28.3, and native `keepidle' is Linux only. -%% Linux rejects an idle time above 32767 seconds, and a rejected raw -%% option is silently ignored. +%% same value. Probes are sent every 5 seconds, or at the idle time if that +%% is shorter, and the connection is dropped after 3 unanswered probes. Raw +%% options are used because the native ones only exist since OTP 28.3, and +%% native `keepidle' is Linux only. Linux rejects an idle time above 32767 +%% seconds, and a rejected raw option is silently ignored. tcp_keepalive_opts(OS, Timeout) -> Idle = min(32767, max(1, Timeout div 1000)), - Interval = 5, + Interval = min(Idle, 5), Count = 3, case OS of {unix, linux} -> @@ -1354,7 +1354,7 @@ prioritise_oldest_test() -> ?assertMatch({{value, 1}, _}, queue:out(Q)). tcp_keepalive_gun_opts_test_() -> - KeepaliveOpts = expected_tcp_keepalive_opts(os:type(), 30), + KeepaliveOpts = expected_tcp_keepalive_opts(os:type(), 30, 5), %% The raw option that sets the idle time, on systems that have one IdleRaw = [O || {raw, _, N, _} = O <- KeepaliveOpts, N =:= 4 orelse N =:= 16#10], CallerIdleRaw = [{raw, L, N, <<60:32/native>>} || {raw, L, N, _} <- IdleRaw], @@ -1377,7 +1377,7 @@ tcp_keepalive_gun_opts_test_() -> TCPOpts([{keepalive, 30_000}]) ), ?_assertEqual( - expected_tcp_keepalive_opts(os:type(), 1) ++ [{nodelay, true}], + expected_tcp_keepalive_opts(os:type(), 1, 1) ++ [{nodelay, true}], TCPOpts([{keepalive, 500} | TCP]) ), ?_assertEqual([{keepalive, false}], WithTCPOpts([{keepalive, false}])), @@ -1398,26 +1398,28 @@ tcp_keepalive_gun_opts_test_() -> tcp_keepalive_opts_test_() -> Opts = fun(OS, Timeout) -> [O || {_Name, O} <- tcp_keepalive_opts(OS, Timeout)] end, [ - ?_assertEqual(expected_tcp_keepalive_opts(OS, Idle), Opts(OS, Timeout)) + ?_assertEqual(expected_tcp_keepalive_opts(OS, Idle, Interval), Opts(OS, Timeout)) || OS <- [{unix, linux}, {unix, darwin}, {unix, freebsd}, {win32, nt}], - {Timeout, Idle} <- [{30_000, 30}, {40_000_000, 32767}] + {Timeout, Idle, Interval} <- [ + {500, 1, 1}, {3_000, 3, 3}, {30_000, 30, 5}, {40_000_000, 32767, 5} + ] ]. -expected_tcp_keepalive_opts({unix, linux}, Idle) -> +expected_tcp_keepalive_opts({unix, linux}, Idle, Interval) -> [ {keepalive, true}, {raw, 6, 4, <>}, - {raw, 6, 5, <<5:32/native>>}, + {raw, 6, 5, <>}, {raw, 6, 6, <<3:32/native>>} ]; -expected_tcp_keepalive_opts({unix, darwin}, Idle) -> +expected_tcp_keepalive_opts({unix, darwin}, Idle, Interval) -> [ {keepalive, true}, {raw, 6, 16#10, <>}, - {raw, 6, 16#101, <<5:32/native>>}, + {raw, 6, 16#101, <>}, {raw, 6, 16#102, <<3:32/native>>} ]; -expected_tcp_keepalive_opts(_OS, _Idle) -> +expected_tcp_keepalive_opts(_OS, _Idle, _Interval) -> [{keepalive, true}]. -endif. From bbd0f58a7f133304eaf8a9eec2e480fd4af5dcc0 Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:52:33 +0300 Subject: [PATCH 5/6] fix: read transport and transport_opts in any position gun_opts/2 took transport_opts only when it came after transport, and dropped transport itself when no transport_opts followed it. With the keepalive defaults, a {keepalive, false} in a transport_opts that came first, or that had no transport, was overridden. transport and transport_opts are now read with proplists, the first of each, before the keepalive defaults are added. Without transport, transport_opts applies to the transport gun picks from the port. This also changes, for pool options that used to lose them: - a transport without transport_opts is used, so {transport, tls} on a port other than 443 connects with TLS instead of plain TCP; - a proxy's transport and tls_opts are used for the connection to the proxy, because parse_proxy_opts/1 puts transport_opts first; - a transport_opts that is now used replaces gun's default send timeouts, as one that came after transport already did. --- changelog.md | 3 ++ src/ehttpc.erl | 78 ++++++++++++++++++++++++++++++++++--------- test/ehttpc_tests.erl | 10 +++++- 3 files changed, 75 insertions(+), 16 deletions(-) diff --git a/changelog.md b/changelog.md index e113d35..98c9499 100644 --- a/changelog.md +++ b/changelog.md @@ -13,6 +13,9 @@ being dropped for `tcp` and passed to the TLS options for `tls`. They now go to `gen_tcp`. A connection that sets them fails before OTP 28.3, and one that sets `keepidle` fails on macOS. +- Fixed `transport_opts` being ignored when it came before `transport` or when `transport` + was not set, and `transport` being ignored without `transport_opts`. A `proxy` that sets + `transport` or `tls_opts` now uses them for the connection to the proxy. ## 0.7.6 diff --git a/src/ehttpc.erl b/src/ehttpc.erl index d2170ff..cfb848c 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -501,7 +501,33 @@ gun_opts(Opts) -> %% Link with client process directly. supervise => false }), - with_tcp_keepalive(proplists:get_value(keepalive, Opts, infinity), GunOpts). + TransportGunOpts = transport_gun_opts( + proplists:get_value(transport, Opts), + proplists:get_value(transport_opts, Opts), + proplists:get_value(port, Opts) + ), + with_tcp_keepalive( + proplists:get_value(keepalive, Opts, infinity), maps:merge(GunOpts, TransportGunOpts) + ). + +%% `transport' and `transport_opts' can be anywhere in the pool options, and +%% the first of each is used. Without `transport', `transport_opts' applies +%% to the transport gun picks from the port. +transport_gun_opts(undefined, undefined, _Port) -> + #{}; +transport_gun_opts(undefined, TransportOpts, Port) -> + transport_gun_opts(gun:default_transport(Port), TransportOpts); +transport_gun_opts(Transport, undefined, _Port) -> + #{transport => Transport}; +transport_gun_opts(Transport, TransportOpts, _Port) -> + transport_gun_opts(Transport, TransportOpts). + +transport_gun_opts(tcp, TransportOpts) -> + {TCPOpts, _} = split_transport_opts(TransportOpts), + #{transport => tcp, tcp_opts => TCPOpts}; +transport_gun_opts(Transport, TransportOpts) when Transport == tls; Transport == ssl -> + {TCPOpts, TLSOpts} = split_transport_opts(TransportOpts), + #{transport => Transport, tcp_opts => TCPOpts, tls_opts => TLSOpts}. %% The `keepalive' option is the idle time in milliseconds before TCP %% keepalive probes start. `{keepalive, false}' in `transport_opts' turns @@ -575,20 +601,6 @@ gun_opts([{connect_timeout, ConnectTimeout} | Opts], Acc) -> gun_opts(Opts, Acc#{connect_timeout => ConnectTimeout}); gun_opts([{protocols, Protocols} | Opts], Acc) -> gun_opts(Opts, Acc#{protocols => Protocols}); -gun_opts([{transport, Transport} | Opts0], Acc0) -> - Acc1 = Acc0#{transport => Transport}, - case lists:keytake(transport_opts, 1, Opts0) of - {value, {_, TransportOpts}, Opts} when Transport == tcp -> - {TCPOpts, _} = split_transport_opts(TransportOpts), - Acc = Acc1#{tcp_opts => TCPOpts}; - {value, {_, TransportOpts}, Opts} when Transport == tls; Transport == ssl -> - {TCPOpts, TLSOpts} = split_transport_opts(TransportOpts), - Acc = Acc1#{tcp_opts => TCPOpts, tls_opts => TLSOpts}; - false -> - Acc = Acc0, - Opts = Opts0 - end, - gun_opts(Opts, Acc); gun_opts([_ | Opts], Acc) -> %% ignore by default gun_opts(Opts, Acc). @@ -1405,6 +1417,42 @@ tcp_keepalive_opts_test_() -> ] ]. +transport_gun_opts_test_() -> + Transport = fun(Opts) -> maps:with([transport, tcp_opts, tls_opts], gun_opts(Opts)) end, + #{opts := ProxyOpts} = parse_proxy_opts([ + {host, "example.com"}, + {port, 443}, + {transport, tcp}, + {proxy, #{ + host => "proxy", port => 3128, transport => tls, tls_opts => [{verify, verify_none}] + }} + ]), + [ + ?_assertEqual( + #{transport => tcp, tcp_opts => [{keepalive, false}]}, + Transport([ + {transport_opts, [{keepalive, false}]}, {transport, tcp}, {keepalive, 30_000} + ]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [{nodelay, true}], tls_opts => [{verify, verify_none}]}, + Transport([{transport_opts, [{nodelay, true}, {verify, verify_none}]}, {transport, tls}]) + ), + ?_assertEqual(#{transport => tls}, Transport([{port, 8443}, {transport, tls}])), + ?_assertEqual( + #{transport => tcp, tcp_opts => [{keepalive, false}]}, + Transport([{port, 8080}, {transport_opts, [{keepalive, false}]}, {keepalive, 30_000}]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [], tls_opts => [{verify, verify_none}]}, + Transport([{port, 443}, {transport_opts, [{verify, verify_none}]}]) + ), + ?_assertEqual( + #{transport => tls, tcp_opts => [], tls_opts => [{verify, verify_none}]}, + Transport(ProxyOpts) + ) + ]. + expected_tcp_keepalive_opts({unix, linux}, Idle, Interval) -> [ {keepalive, true}, diff --git a/test/ehttpc_tests.erl b/test/ehttpc_tests.erl index a184576..85901f1 100644 --- a/test/ehttpc_tests.erl +++ b/test/ehttpc_tests.erl @@ -902,9 +902,17 @@ tcp_keepalive_test_() -> ?assertEqual({ok, [{keepalive, true}]}, inet:getopts(Socket, [keepalive])), assert_tcp_keepalive_timers(Socket, os:type()) end, + OptOutPoolOpts = [{transport_opts, [{keepalive, false}]}, {transport, tcp} | PoolOpts], + CheckOptOut = fun() -> + ?assertMatch({ok, 200, _, _}, ehttpc:request(?POOL, get, req(), 1000, 0)), + {_, #{client := Client}} = ehttpc:get_state(?POOL), + #{socket := Socket} = gun:info(Client), + ?assertEqual({ok, [{keepalive, false}]}, inet:getopts(Socket, [keepalive])) + end, [ {"no transport_opts", fun() -> ?WITH(ServerOpts, PoolOpts, Check()) end}, - {"tcp transport_opts", fun() -> ?WITH(ServerOpts, TCPPoolOpts, Check()) end} + {"tcp transport_opts", fun() -> ?WITH(ServerOpts, TCPPoolOpts, Check()) end}, + {"opt-out before transport", fun() -> ?WITH(ServerOpts, OptOutPoolOpts, CheckOptOut()) end} ]. %% Idle time, probe interval and probe count, in this order. From 6ea5b3b41377245800d8f79b1f222db82341b97b Mon Sep 17 00:00:00 2001 From: MorganaFuture <103630661+MorganaFuture@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:13:57 +0300 Subject: [PATCH 6/6] fix: apply the gen_tcp transport_opts to the proxy connection With a proxy, parse_proxy_opts/1 passed the whole transport_opts to the target's tls_opts, which ssl uses on the tunnel, so the gen_tcp options there never reached a socket. The keepalive defaults went to the connection to the proxy, the only TCP connection, and a {keepalive, false} in transport_opts did not turn them off. The gen_tcp options in transport_opts now go to the connection to the proxy, before the proxy's own tls_opts, and only the TLS options are left for the target. When there are any, they set the proxy connection's tcp_opts, so it no longer gets gun's default send timeouts. --- changelog.md | 6 ++++- src/ehttpc.erl | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 98c9499..877d9fa 100644 --- a/changelog.md +++ b/changelog.md @@ -15,7 +15,11 @@ `keepidle` fails on macOS. - Fixed `transport_opts` being ignored when it came before `transport` or when `transport` was not set, and `transport` being ignored without `transport_opts`. A `proxy` that sets - `transport` or `tls_opts` now uses them for the connection to the proxy. + `transport` now uses it and its `tls_opts` for the connection to the proxy. +- With a `proxy`, the `gen_tcp` options in `transport_opts`, including the keepalive ones, + now apply to the connection to the proxy, the only TCP connection, and only the TLS + options are used for the target. All of them used to be passed to the target's TLS + options. ## 0.7.6 diff --git a/src/ehttpc.erl b/src/ehttpc.erl index cfb848c..fa58e43 100644 --- a/src/ehttpc.erl +++ b/src/ehttpc.erl @@ -1253,7 +1253,7 @@ parse_proxy_opts(Opts) -> #{opts => Opts, origin => undefined}; #{host := _, port := _} = ProxyOpts0 -> %% We open connection to proxy, then issue `gun:connect' to target host. - {Origin, NewOpts} = + {Origin0, NewOpts0} = lists:foldl( fun(Key, {OriginAcc, GunAcc}) -> swap(Key, OriginAcc, GunAcc) @@ -1261,9 +1261,27 @@ parse_proxy_opts(Opts) -> {ProxyOpts0, proplists:delete(proxy, Opts)}, [host, port, transport, {tls_opts, transport_opts}] ), + {Origin, NewOpts} = move_tcp_opts_to_proxy(Origin0, NewOpts0), #{opts => NewOpts, origin => Origin} end. +%% The connection to the proxy is the only TCP connection, so the target's +%% `gen_tcp' options apply to it, before the proxy's own `tls_opts', and only +%% the TLS options are left for the target. +move_tcp_opts_to_proxy(#{tls_opts := TransportOpts} = Origin, Opts) -> + case split_transport_opts(TransportOpts) of + {[], TLSOpts} -> + {Origin#{tls_opts => TLSOpts}, Opts}; + {TCPOpts, TLSOpts} -> + ProxyTransportOpts = TCPOpts ++ proplists:get_value(transport_opts, Opts, []), + { + Origin#{tls_opts => TLSOpts}, + [{transport_opts, ProxyTransportOpts} | proplists:delete(transport_opts, Opts)] + } + end; +move_tcp_opts_to_proxy(Origin, Opts) -> + {Origin, Opts}. + swap(Key, Map, Proplist) when is_atom(Key) -> swap({Key, Key}, Map, Proplist); swap({KeyM, KeyP}, Map0, Proplist0) when is_map_key(KeyM, Map0) -> @@ -1453,6 +1471,47 @@ transport_gun_opts_test_() -> ) ]. +proxy_tcp_opts_test_() -> + Parse = fun(ProxyOpts) -> + #{opts := Opts, origin := Origin} = parse_proxy_opts([ + {host, "example.com"}, + {port, 443}, + {transport, tls}, + {transport_opts, [{keepalive, false}, {nodelay, true}, {verify, verify_none}]}, + {keepalive, 30_000}, + {proxy, ProxyOpts} + ]), + { + maps:with([transport, tcp_opts, tls_opts], gun_opts(Opts)), + maps:with([transport, tls_opts], Origin) + } + end, + [ + ?_assertEqual( + { + #{transport => tcp, tcp_opts => [{keepalive, false}, {nodelay, true}]}, + #{transport => tls, tls_opts => [{verify, verify_none}]} + }, + Parse(#{host => "proxy", port => 3128}) + ), + ?_assertEqual( + { + #{ + transport => tls, + tcp_opts => [{keepalive, false}, {nodelay, true}], + tls_opts => [{server_name_indication, "proxy"}] + }, + #{transport => tls, tls_opts => [{verify, verify_none}]} + }, + Parse(#{ + host => "proxy", + port => 3128, + transport => tls, + tls_opts => [{server_name_indication, "proxy"}] + }) + ) + ]. + expected_tcp_keepalive_opts({unix, linux}, Idle, Interval) -> [ {keepalive, true},