Skip to content

net: race family autoselection attempts - #66229

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:net/parallel-happy-eyeballs
Open

mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:net/parallel-happy-eyeballs

Conversation

@mcollina

Copy link
Copy Markdown
Member

Keep pending TCP connections alive when starting
fallback attempts. The first successful connection wins; fixed
local ports retain sequential attempts.

——

AI generated, humanly reviewed.

Keep pending TCP connections alive when starting
 fallback attempts. The first successful connection wins; fixed
 local ports retain sequential attempts.

Assisted-by: pi coding agent
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/net

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. net Issues and PRs related to the net subsystem. labels Sep 23, 2026
@mcollina

Copy link
Copy Markdown
Member Author

@nodejs/tsc PTAL. Also: how we should consider this wrt semver?

I would propose a minor+”backing for LTS”, so we can land on 26 and see. If you want to push to 27.. it’s also ok.

@ShogunPanda ShogunPanda left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@pimterry pimterry left a comment

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.

There's some notable bugs in here, and the tests are failing.

Aside from that, I think this is indeed semver-mejor. There's a fair few observable changes that are unavoidable with this approach, like:

  • All access to any local binding state like socket.address() now stops working until the connection completes - it just returns {} just like before calling connect(). With this design, it fundamentally can never return a local address, because we're using multiple addresses in parallel until one succeeds.
  • The various connectionAttempt* events now all behave very differently: connectionAttempt events no longer pair with the others (they fire in parallel, not series) and after a timeout event you can now see a success or failure event for the same attempt.
  • AggregateError.errors was always in happy eyeballs order before, and matched the attempt events - now it's in arbitrary completion order.

I do still think we should do this though, as semver-major. It's a better option that might reduce lots of the timeout issues we've seen. It does change a chunk of observable behaviour en route, but I think the end result is reasonable and worth it.

Comment thread lib/net.js Outdated
handle.close();
ArrayPrototypePush(context.errors, new ExceptionWithHostPort(err, 'bind', localAddress, localPort));
internalConnectMultiple(context);
scheduleConnectionAttempt(context, 10);

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.

This adds a 10ms delay after explicit connection rejections.

With this PR as is, for the common case of IPv4-only localhost server (where we try IPv6, fail with ECONNRESET, then fallback to IPv4) connection time jumps from sub millisecond currently to 10ms minimum.

The RFC does suggest we should use a 10ms minimum, but all the explanation around it makes it clear that this is worrying about packet loss & timeouts, and refers to simple implementations only. Non-timeout cases should clearly bypass this imo.

Imo we should keep the old behaviour, and instantly move forward after errors.

Comment thread lib/net.js Outdated
self.destroy(context.errors.length === 0 ?
new ERR_SOCKET_CONNECTION_TIMEOUT() : new NodeAggregateError(context.errors));
}
return;

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.

If the final candidate fails explicitly but there are still other previous requests pending, we can get stuck.

Two routes:

  • A final call to scheduleConnectionAttempt/internalConnectMultiple after sync failure (e.g. blocklist) ends up here. Given preceeding requests still pending it skips the destroy() call.
  • After async final failure with pending requests, in afterConnectMultiple we similarly return without any action at 2205.

If we hit either case, we just do nothing. We've cleared all the timeouts, but we don't clean up.

If the pending requests never resolve (no response at all) then in practice I think we wait here until the OS times out for us - for Linux defaults for example it looks like this will wait for a little over 2 minutes.

I think we need a final step here. Maybe wait one more timeout and then kill everything? Would be nice to bound that extra timeout tighter somehow (use the correct remaining timeout from the latest of the pending requests somehow) but probably not worth the extra complexity.

Comment thread lib/net.js Outdated
context[kTimeout] = attempt ?
setTimeout(internalConnectMultipleTimeout, delay, context, attempt) :
setTimeout(internalConnectMultiple, delay, context);
if (context.socket._handle?.hasRef?.() === false) context[kTimeout].unref();

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.

I was surprised by the ?. after hasRef here - I think that's been added as a workaround that hides a worse bug:

All of this will also be called as part of tlssocket.connect, where _handle is a TLSWrap, not a TCPWrap, and it looks like TLSWrap doesn't currently have hasRef, so this'll breaks unref behaviour for all TLS.

I think that's just a straight TLSWrap bug, and so we should add hasRef there and then we can drop the ?. here (if the handle is set, hasRef should work).

@jasnell jasnell added the semver-major PRs that contain breaking changes and should be released in the next major version. label Sep 23, 2026
Run the first family autoselection attempt on the socket's own
handle, so state set on it before connecting (TLS wrapping, onread
buffers, socket options) is kept. Additional attempts use new
handles and replace it only if they win.

Start the next attempt immediately after an explicit failure. If
the last attempt fails while others are pending, give them one more
attempt timeout. Report AggregateError errors in attempt order.

Add hasRef() to the TLSWrap proxied methods.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.77249% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.41%. Comparing base (5c5bd22) to head (1924295).
⚠️ Report is 347 commits behind head on main.

Files with missing lines Patch % Lines
lib/net.js 86.70% 23 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66229      +/-   ##
==========================================
+ Coverage   90.24%   90.41%   +0.17%     
==========================================
  Files         789      791       +2     
  Lines      270613   275713    +5100     
  Branches    51802    52894    +1092     
==========================================
+ Hits       244208   249292    +5084     
+ Misses      16870    16822      -48     
- Partials     9535     9599      +64     
Files with missing lines Coverage Δ
lib/internal/tls/wrap.js 95.61% <100.00%> (+0.44%) ⬆️
lib/net.js 94.77% <86.70%> (+0.34%) ⬆️

... and 224 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Oct 2, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Oct 2, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

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

Labels

needs-ci PRs that need a full CI run. net Issues and PRs related to the net subsystem. semver-major PRs that contain breaking changes and should be released in the next major version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants