Windows: avoiding the loopback connect stall with SIO_TCP_INITIAL_RTO

Marcel Jamin via curl-library <[email protected]> Tue, 4 Aug 2026 18:54:25 +0200
Newsgroups gmane.comp.web.curl.library
Message-ID <CAAUq485ED0DAc8j42UYD19Ck0sb6QM-hviNt1AdzAnQ6vih1dw@mail.gmail.com>
--===============7901778302714023591==
Content-Type: multipart/alternative; boundary="000000000000a1e46406583b823b"

--000000000000a1e46406583b823b
Content-Type: text/plain; charset="UTF-8"

Hi,

On Windows, curl against a service listening only on 127.0.0.1 costs
~212ms via "localhost" and ~3-9ms via "127.0.0.1". That is the problem
from #14144 / #2281 and from

  https://daniel.haxx.se/blog/2024/08/14/slow-tcp-connect-on-windows/

I agree with the not-a-curl-bug call on #14144: the ~2s that Windows
takes to report a refused connect is platform behaviour, not a curl
defect. I am not asking to revisit that.

What I would like to raise is a per-socket opt-out that does not appear
in either issue or in the blog post. libuv has shipped it since 2018,
it needs no detection of the stall, and it takes the refusal from
~2000ms to ~0.3ms.


The mechanism
-------------

The stall is the SYN retransmission budget. Isolated with raw Winsock
(ConnectEx + IOCP) against a closed loopback port on Windows 11,
varying only TCP_INITIAL_RTO_PARAMETERS.MaxSynRetransmissions via
SIO_TCP_INITIAL_RTO:

  no ioctl (system default = 4)      2026 ms
  0   (DEFAULT)                      2015 ms
  1                                   506 ms
  255 (UNSPECIFIED)                  2023 ms
  254 (NO_SYN_RETRANSMISSIONS)          0.3 ms

Linear at ~505ms per retransmission. Reproduced on two independent
Windows machines.

The sentinel encoding in mstcpip.h is: 0 means "default", 255 means
"unspecified", and 254 is the one that means "none".

This cannot be pushed onto users as a Windows configuration change. The
TCP template surface (Set-NetTCPSetting bound to a prefix by
New-NetTransportFilter) accepts MaxSynRetransmissions only in the range
2-8 and rejects 254 outright, so the best achievable system-wide is
~1010ms. Zero is reachable only per-socket, which is why it has to be
done in the application.


curl is still affected
----------------------

  curl 8.21.0 (Windows) libcurl/8.21.0 Schannel zlib/1.3.2 WinIDN
  Release-Date: 2026-06-24

Against a dev server bound to 127.0.0.1 only:

  http://localhost:4200/    212-225 ms, consistently
  http://127.0.0.1:4200/    3-9 ms

The ~212ms is the 200ms happy eyeballs default plus the actual connect.
Same figures on curl 8.10.1 (mingw). On the same machine and the same
ports, node reports ECONNREFUSED in 3ms, solely because libuv sets this
ioctl.


Proposal
--------

In cf_socket_open(), or immediately before do_connect(), on Windows 10
1709+, when the destination address is loopback:

  TCP_INITIAL_RTO_PARAMETERS rto;
  memset(&rto, 0, sizeof(rto));
  rto.Rtt = TCP_INITIAL_RTO_DEFAULT_RTT;
  rto.MaxSynRetransmissions = TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS;
  WSAIoctl(sockfd, SIO_TCP_INITIAL_RTO, &rto, sizeof(rto),
           NULL, 0, &bytes, NULL, NULL);

Reference implementation, libuv src/win/tcp.c, uv__tcp_try_connect():

  if (uv__windows10_version1709() && uv__is_loopback(&converted)) {
    memset(&retransmit_ioctl, 0, sizeof(retransmit_ioctl));
    retransmit_ioctl.Rtt = TCP_INITIAL_RTO_DEFAULT_RTT;
    retransmit_ioctl.MaxSynRetransmissions =
        TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS;
    WSAIoctl(handle->socket, SIO_TCP_INITIAL_RTO, &retransmit_ioctl,
             sizeof(retransmit_ioctl), NULL, 0, &bytes, NULL, NULL);
  }

On loopback there is no medium to lose a SYN on, so retransmission is
pure cost.

Failure of the ioctl is safely ignorable - it is an optimisation, and
older Windows simply keeps today's behaviour.


Effect
------

The IPv6 attempt to [::1] fails in ~0.3ms rather than stalling, so the
IPv4 attempt starts immediately instead of after the happy eyeballs
timer: roughly 212ms -> ~1ms for the common case of a service bound to
IPv4 only.

It also helps outside happy eyeballs. Any connect to a closed loopback
port, --ipv4 included, currently costs ~2s and would cost ~0.3ms. That
matters for scripts that poll a port waiting for a service to come up.


Notes
-----

1) CURLOPT_HAPPY_EYEBALLS_TIMEOUT_MS = 0  works, but:

 - every user has to know about it and opt in per invocation;
 - it disables happy eyeballs staggering for all destinations,
   including real network ones where the stagger is doing useful work;
 - it does not help the non-happy-eyeballs cases.

The ioctl is automatic, scoped to loopback, and leaves behaviour on
real networks untouched.

2) Requires Windows 10 1709+ for the
TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS sentinel; SIO_TCP_INITIAL_RTO
itself is older, so a configure/header check is needed for the constant.

Regards,
Marcel

--000000000000a1e46406583b823b
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable

<div dir=3D"ltr"><div class=3D"gmail_default" style=3D"font-size:small;colo=
r:rgb(0,0,0)"><font face=3D"monospace">Hi,<br><br>On Windows, curl against =
a service listening only on 127.0.0.1 costs<br>~212ms via &quot;localhost&q=
uot; and ~3-9ms via &quot;127.0.0.1&quot;. That is the problem<br>from #141=
44 / #2281 and from<br><br>=C2=A0 <a href=3D"https://daniel.haxx.se/blog/20=
24/08/14/slow-tcp-connect-on-windows/">https://daniel.haxx.se/blog/2024/08/=
14/slow-tcp-connect-on-windows/</a><br><br>I agree with the not-a-curl-bug =
call on #14144: the ~2s that Windows<br>takes to report a refused connect i=
s platform behaviour, not a curl<br>defect. I am not asking to revisit that=
.<br><br>What I would like to raise is a per-socket opt-out that does not a=
ppear<br>in either issue or in the blog post. libuv has shipped it since 20=
18,<br>it needs no detection of the stall, and it takes the refusal from<br=
>~2000ms to ~0.3ms.<br><br><br>The mechanism<br>-------------<br><br>The st=
all is the SYN retransmission budget. Isolated with raw Winsock<br>(Connect=
Ex + IOCP) against a closed loopback port on Windows 11,<br>varying only TC=
P_INITIAL_RTO_PARAMETERS.MaxSynRetransmissions via<br>SIO_TCP_INITIAL_RTO:<=
br><br>=C2=A0 no ioctl (system default =3D 4) =C2=A0 =C2=A0 =C2=A02026 ms<b=
r>=C2=A0 0 =C2=A0 (DEFAULT) =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=
=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A02015 ms<br>=C2=A0 1 =C2=A0 =C2=A0 =C2=A0 =C2=
=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =
=C2=A0 =C2=A0 =C2=A0 506 ms<br>=C2=A0 255 (UNSPECIFIED) =C2=A0 =C2=A0 =C2=
=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A02023 ms<br>=C2=A0 254 (NO_SYN_=
RETRANSMISSIONS) =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A00.3 ms<br><br>Linear at =
~505ms per retransmission. Reproduced on two independent<br>Windows machine=
s.<br><br>The sentinel encoding in mstcpip.h is: 0 means &quot;default&quot=
;, 255 means<br>&quot;unspecified&quot;, and 254 is the one that means &quo=
t;none&quot;.<br><br>This cannot be pushed onto users as a Windows configur=
ation change. The<br>TCP template surface (Set-NetTCPSetting bound to a pre=
fix by<br>New-NetTransportFilter) accepts MaxSynRetransmissions only in the=
 range<br>2-8 and rejects 254 outright, so the best achievable system-wide =
is<br>~1010ms. Zero is reachable only per-socket, which is why it has to be=
<br>done in the application.<br><br><br>curl is still affected<br>---------=
-------------<br><br>=C2=A0 curl 8.21.0 (Windows) libcurl/8.21.0 Schannel z=
lib/1.3.2 WinIDN<br>=C2=A0 Release-Date: 2026-06-24<br><br>Against a dev se=
rver bound to 127.0.0.1 only:<br><br>=C2=A0 <a href=3D"http://localhost:420=
0/">http://localhost:4200/</a> =C2=A0 =C2=A0212-225 ms, consistently<br>=C2=
=A0 <a href=3D"http://127.0.0.1:4200/">http://127.0.0.1:4200/</a> =C2=A0 =
=C2=A03-9 ms<br><br>The ~212ms is the 200ms happy eyeballs default plus the=
 actual connect.<br>Same figures on curl 8.10.1 (mingw). On the same machin=
e and the same<br>ports, node reports ECONNREFUSED in 3ms, solely because l=
ibuv sets this<br>ioctl.<br><br><br>Proposal<br>--------<br><br>In cf_socke=
t_open(), or immediately before do_connect(), on Windows 10<br>1709+, when =
the destination address is loopback:<br><br>=C2=A0 TCP_INITIAL_RTO_PARAMETE=
RS rto;<br>=C2=A0 memset(&amp;rto, 0, sizeof(rto));<br>=C2=A0 rto.Rtt =3D T=
CP_INITIAL_RTO_DEFAULT_RTT;<br>=C2=A0 rto.MaxSynRetransmissions =3D TCP_INI=
TIAL_RTO_NO_SYN_RETRANSMISSIONS;<br>=C2=A0 WSAIoctl(sockfd, SIO_TCP_INITIAL=
_RTO, &amp;rto, sizeof(rto),<br>=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0NU=
LL, 0, &amp;bytes, NULL, NULL);<br><br>Reference implementation, libuv src/=
win/tcp.c, uv__tcp_try_connect():<br><br>=C2=A0 if (uv__windows10_version17=
09() &amp;&amp; uv__is_loopback(&amp;converted)) {<br>=C2=A0 =C2=A0 memset(=
&amp;retransmit_ioctl, 0, sizeof(retransmit_ioctl));<br>=C2=A0 =C2=A0 retra=
nsmit_ioctl.Rtt =3D TCP_INITIAL_RTO_DEFAULT_RTT;<br>=C2=A0 =C2=A0 retransmi=
t_ioctl.MaxSynRetransmissions =3D<br>=C2=A0 =C2=A0 =C2=A0 =C2=A0 TCP_INITIA=
L_RTO_NO_SYN_RETRANSMISSIONS;<br>=C2=A0 =C2=A0 WSAIoctl(handle-&gt;socket, =
SIO_TCP_INITIAL_RTO, &amp;retransmit_ioctl,<br>=C2=A0 =C2=A0 =C2=A0 =C2=A0 =
=C2=A0 =C2=A0 =C2=A0sizeof(retransmit_ioctl), NULL, 0, &amp;bytes, NULL, NU=
LL);<br>=C2=A0 }<br><br>On loopback there is no medium to lose a SYN on, so=
 retransmission is<br>pure cost.<br><br>Failure of the ioctl is safely igno=
rable - it is an optimisation, and<br>older Windows simply keeps today&#39;=
s behaviour.<br><br><br>Effect<br>------<br><br>The IPv6 attempt to [::1] f=
ails in ~0.3ms rather than stalling, so the<br>IPv4 attempt starts immediat=
ely instead of after the happy eyeballs<br>timer: roughly 212ms -&gt; ~1ms =
for the common case of a service bound to<br>IPv4 only.<br><br>It also help=
s outside happy eyeballs. Any connect to a closed loopback<br>port, --ipv4 =
included, currently costs ~2s and would cost ~0.3ms. That<br>matters for sc=
ripts that poll a port waiting for a service to come up.<br><br><br>Notes<b=
r>-----<br><br>1) CURLOPT_HAPPY_EYEBALLS_TIMEOUT_MS =3D 0=C2=A0=C2=A0works,=
 but:<br><br>=C2=A0- every user has to know about it and opt in per invocat=
ion;<br>=C2=A0- it disables happy eyeballs staggering for all destinations,=
<br>=C2=A0 =C2=A0including real network ones where the stagger is doing use=
ful work;<br>=C2=A0- it does not help the non-happy-eyeballs cases.<br><br>=
The ioctl is automatic, scoped to loopback, and leaves behaviour on<br>real=
 networks untouched.<br><br>2) Requires Windows 10 1709+ for the<br>TCP_INI=
TIAL_RTO_NO_SYN_RETRANSMISSIONS sentinel; SIO_TCP_INITIAL_RTO<br>itself is =
older, so a configure/header check is needed for the constant.<br><br>Regar=
ds,<br>Marcel</font></div></div>

--000000000000a1e46406583b823b--

--===============7901778302714023591==
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Content-Disposition: inline

-- 
Unsubscribe: https://lists.haxx.se/mailman/listinfo/curl-library
Etiquette:   https://curl.se/mail/etiquette.html

--===============7901778302714023591==--