Skip to content

feat: enable TCP keepalive from the keepalive pool option - #73

Merged
zmstone merged 6 commits into
emqx:mainfrom
MorganaFuture:tcp-keepalive-from-pool-option
Sep 30, 2026
Merged

zmstone merged 6 commits into
emqx:mainfrom
MorganaFuture:tcp-keepalive-from-pool-option

Conversation

@MorganaFuture

@MorganaFuture MorganaFuture commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Refs emqx/emqx#19170

The keepalive pool option was accepted but ignored. Now {keepalive, T}, where T is a positive integer in milliseconds, adds TCP keepalive options to tcp_opts for tcp and tls, with or without transport_opts. infinity or no keepalive adds nothing.

On Linux and macOS it adds {keepalive, true} and raw IPPROTO_TCP options for the idle time (T div 1000 seconds, kept between 1 and 32767), the probe interval (5 s, or the idle time if shorter) and the probe count (3). The option numbers are 4/5/6 on Linux and 16#10/16#101/16#102 on macOS, as in emqx_schema:tcp_keepalive_opts/4. Other systems get only {keepalive, true}.

{keepalive, false} in transport_opts turns it off. Any other keepalive option there, native or raw IPPROTO_TCP, replaces only the matching one.

Raw options are used on every OTP version, not native ones on OTP 28.3+, because on macOS with OTP 29 gen_tcp:connect/3 raises badarg for {keepidle, 30}, while the raw options are accepted and read back correctly.

is_gen_tcp_option/1 now also accepts keepidle, keepintvl and keepcnt. Before, they were dropped from transport_opts for tcp and passed to the TLS options for tls. Now they go to gen_tcp, which raises badarg for them before OTP 28.3.

transport and transport_opts are now read with proplists (first of each) instead of in the gun_opts/2 loop, which only honored transport_opts after transport and dropped a transport not followed by transport_opts. Without transport, transport_opts applies to the transport gun picks from the port. So a lone {transport, tls} on a port other than 443 now uses TLS, and a proxy that sets transport now uses it and its tls_opts for the connection to the proxy. A transport_opts that used to be ignored now sets tcp_opts, so those pools lose gun's default send timeouts, as pools with transport then transport_opts already did.

With a proxy, the gen_tcp options in transport_opts now go to the connection to the proxy, the only TCP connection, and only the TLS options go to the target. Before, all of them went to the target's tls_opts, so {keepalive, false} there left the keepalive defaults on the proxy connection. If there are any, they set the proxy connection's tcp_opts, so it loses gun's default send timeouts.

emqx always passes transport before transport_opts, and its only proxy (Snowflake) sets just host and port with TLS-only transport_opts, so it is not affected.

App vsn bumped to 0.7.7.

Tests

  • tcp_keepalive_opts_test_ and tcp_keepalive_gun_opts_test_ in src/ehttpc.erl: the mapping per OS, the 1 and 32767 second limits, the interval cap, tcp, tls and no transport_opts, and keepalive options set by the caller.
  • transport_gun_opts_test_ in src/ehttpc.erl: transport_opts before transport, no transport, a lone transport, and a proxy with transport and tls_opts.
  • proxy_tcp_opts_test_ in src/ehttpc.erl: with a proxy, the gen_tcp options and {keepalive, false} go to the connection to the proxy, and the TLS options to the target.
  • tcp_keepalive_test_ in test/ehttpc_tests.erl: sends a request through a pool with {keepalive, 30_000} and reads keepalive and the raw idle, interval and count back from the live socket, with and without transport_opts, and checks that {keepalive, false} before transport leaves keepalive off.

Reverting the interval cap, the transport handling or the proxy split fails the new cases. On OTP 27 and 28 on Linux, make fmt-check, make compile, make xref and make dialyzer pass, and make eunit passes 113/113 with tinyproxy. OTP 25/26 are left to CI.

Comment thread changelog.md Outdated
@@ -1,5 +1,17 @@
# ehttpc changes

## Unreleased

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.7.6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bumped to 0.7.7 along with the app vsn, since 0.7.6 was tagged with #71.

Comment thread src/ehttpc.erl
%% 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}]),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question, send_timeot and send_timeout_close is newly added.
why?
and how it behaved before ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded the comment. It's gun's own default, not a new setting: gun:domain_lookup/3 uses [{send_timeout, 15000}, {send_timeout_close, true}] only when tcp_opts is not set, and the keepalive options set it, so the code keeps the default. On main, only a pool with transport followed by transport_opts set tcp_opts and lost it. Keepalive doesn't change that; the P2 fix does, since transport_opts in any position now sets tcp_opts.

Comment thread src/ehttpc.erl Outdated
Comment on lines +529 to +530
Idle = min(32767, max(1, Timeout div 1000)),
Interval = 5,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cap Interval with Idle.
Interval = min(Idle, 5)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The idle time is at least 1 second, so the interval stays between 1 and 5. Tests cover 500 ms and 3 s.

Comment thread src/ehttpc.erl Outdated
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
%% OTP 28 and later, keepidle is Linux only
%% OTP 28.3 and later, keepidle is Linux only

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, and fixed the same "OTP 28" in the tcp_keepalive_opts/2 comment and the changelog.

Comment thread src/ehttpc.erl
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor explicit keepalive overrides regardless of pool-option order

The opt-out is checked only after gun_opts/2 has processed the pool options, but that parser only consumes transport_opts when it comes after transport. For example, this pool-option list enables TCP keepalive despite the explicit disablement:

[{transport_opts, [{keepalive, false}]},
 {transport, tcp},
 {keepalive, 30_000}]

I checked the live socket against both revisions: the parent has keepalive = false, while this commit has keepalive = true and an idle time of 30 seconds. The parser's order dependency predates this PR, but the new default injection now overrides the caller's requested opt-out. Extract/normalize the transport options independently of their position before adding defaults, and add a test with transport_opts before transport.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. transport and transport_opts (first of each) are now read with proplists before the keepalive defaults, and without transport, transport_opts applies to the transport gun picks from the port. The same override happened through a proxy, where transport_opts went to the target's TLS options; its gen_tcp options now go to the connection to the proxy. Added unit cases for your list, no transport and a proxy, plus a live-socket case with transport_opts first. This also stops a lone transport and a proxy's transport from being dropped; see the commit messages.

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.
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.
With a keepalive below 5 seconds the probe interval was longer than the
idle time before the first probe.
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.
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.
@MorganaFuture
MorganaFuture force-pushed the tcp-keepalive-from-pool-option branch from 20d8e2f to 6ea5b3b Compare September 29, 2026 11:31
@MorganaFuture

Copy link
Copy Markdown
Contributor Author

Rebased on main and addressed the review in separate commits.

@zmstone
zmstone merged commit 568b492 into emqx:main Sep 30, 2026
4 checks passed
@zmstone

zmstone commented Sep 30, 2026

Copy link
Copy Markdown
Member

tagged 0.7.7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants