From 3694a32dff88bdb51fadfd3a20bdaa4485a30343 Mon Sep 17 00:00:00 2001 From: Vinicius Fortuna Date: Wed, 14 Feb 2024 11:43:27 -0500 Subject: [PATCH] feat: create Happy Eyeballs dialer (#176) --- CONTRIBUTING.md | 11 + transport/happyeyeballs.go | 259 +++++++++++++++++++ transport/happyeyeballs_test.go | 428 ++++++++++++++++++++++++++++++++ transport/packet.go | 2 +- 4 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 transport/happyeyeballs.go create mode 100644 transport/happyeyeballs_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2cbdf818..7d9eece2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,17 @@ use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. +# Go Documentation + +The best way to ensure you got the Go doc formatting right is to visualize it. +To visualize the Go documentation you wrote, run: + +```sh +go run golang.org/x/pkgsite/cmd/pkgsite@latest +``` + +Then open http://localhost:8080 on your browser. + # Cross-platform Development ## Building diff --git a/transport/happyeyeballs.go b/transport/happyeyeballs.go new file mode 100644 index 00000000..2137f810 --- /dev/null +++ b/transport/happyeyeballs.go @@ -0,0 +1,259 @@ +// Copyright 2024 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync/atomic" + "time" +) + +/* +HappyEyeballsStreamDialer is a [StreamDialer] that uses [Happy Eyeballs v2] to establish a connection +to the destination address. + +Happy Eyeballs v2 reduces the connection delay when compared to v1, with significant differences when one of the +address lookups times out. V1 will wait for both the IPv4 and IPv6 lookups to return before attempting connections, +while V2 starts connections as soon as it gets a lookup result, with a slight delay if IPv4 arrives before IPv6. + +Go and most platforms provide V1 only, so you will benefit from using the HappyEyeballsStreamDialer in place of the +standard dialer, even if you are not using custom transports. + +[Happy Eyeballs v2]: https://datatracker.ietf.org/doc/html/rfc8305 +*/ +type HappyEyeballsStreamDialer struct { + // Dialer is used to establish the connection attempts. If nil, a direct TCP connection is established. + Dialer StreamDialer + // Resolve is a function to map a host name to IP addresses. See HappyEyeballsResolver. + Resolve HappyEyeballsResolveFunc +} + +// HappyEyeballsResolveFunc performs concurrent hostname resolution for [HappyEyeballsStreamDialer]. +// +// The function should return a channel quickly, and then send the resolution results to it +// as they become available. HappyEyeballsStreamDialer will read the resolutions from the channel. +// The returned channel must be closed when there are no +// more resolutions pending, to indicate that the resolution is done. If that is not +// done, HappyEyeballsStreamDialer will keep waiting. +// +// It's recommended to return a buffered channel with size equal to the number of +// lookups, so that it will never block on write. +// If the channel is unbuffered, you must use select when writing to the channel against +// ctx.Done(), to make sure you don't write when HappyEyeballsStreamDialer is no longer reading. +// Othewise your goroutine will get stuck. +// +// It's recommended to resolve IPv6 and IPv4 in parallel, so the connection attempts +// are started as soon as addresses are received. That's the primary benefit of Happy +// Eyeballs v2. If you resolve in series, and only send the addresses when both +// resolutions are done, you will get behavior similar to Happy Eyeballs v1. +type HappyEyeballsResolveFunc = func(ctx context.Context, hostname string) <-chan HappyEyeballsResolution + +// HappyEyeballsResolution represents a result of a hostname resolution. +// Happy Eyeballs sorts the IPs in a specific way, updating the order as +// new results are received. It's recommended to returns all IPs you receive +// as a group, rather than one IP at a time, since a later IP may be preferred. +type HappyEyeballsResolution struct { + IPs []netip.Addr + Err error +} + +// NewParallelHappyEyeballsResolveFunc creates a [HappyEyeballsResolveFunc] that uses the given list of functions to resolve host names. +// The given functions will all run in parallel, with results being output as they are received. +// Typically you will pass one function for IPv6 and one for IPv4 to achieve Happy Eyballs v2 behavior. +// It takes care of creating the channel and the parallelization and coordination between the calls. +func NewParallelHappyEyeballsResolveFunc(resolveFuncs ...func(ctx context.Context, hostname string) ([]netip.Addr, error)) HappyEyeballsResolveFunc { + return func(ctx context.Context, host string) <-chan HappyEyeballsResolution { + // Use a buffered channel with space for both lookups, to ensure the goroutines won't + // block on channel write if the Happy Eyeballs algorithm is cancelled and no longer reading. + resultsCh := make(chan HappyEyeballsResolution, len(resolveFuncs)) + if len(resolveFuncs) == 0 { + close(resultsCh) + return resultsCh + } + + var pending atomic.Int32 + pending.Store(int32(len(resolveFuncs))) + for _, resolve := range resolveFuncs { + go func(resolve func(ctx context.Context, hostname string) ([]netip.Addr, error), hostname string) { + ips, err := resolve(ctx, hostname) + resultsCh <- HappyEyeballsResolution{ips, err} + if pending.Add(-1) == 0 { + // Close results channel when no other goroutine is pending. + close(resultsCh) + } + }(resolve, host) + } + return resultsCh + } +} + +var _ StreamDialer = (*HappyEyeballsStreamDialer)(nil) + +func (d *HappyEyeballsStreamDialer) dial(ctx context.Context, addr string) (StreamConn, error) { + if d.Dialer != nil { + return d.Dialer.DialStream(ctx, addr) + } + return (&TCPDialer{}).DialStream(ctx, addr) +} + +func newClosedChan() <-chan struct{} { + closedCh := make(chan struct{}) + close(closedCh) + return closedCh +} + +// DialStream implements [StreamDialer]. +func (d *HappyEyeballsStreamDialer) DialStream(ctx context.Context, addr string) (StreamConn, error) { + hostname, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("failed to parse address: %w", err) + } + if net.ParseIP(hostname) != nil { + // Host is already an IP address, just dial the address. + return d.dial(ctx, addr) + } + + // Indicates to attempts that the dialing process is done, so they don't get stuck. + ctx, dialDone := context.WithCancel(ctx) + defer dialDone() + + // HOSTNAME RESOLUTION QUERY HANDLING + // https://datatracker.ietf.org/doc/html/rfc8305#section-3 + resolutionCh := d.Resolve(ctx, hostname) + + // CONNECTION ATTEMPTS + // https://datatracker.ietf.org/doc/html/rfc8305#section-5 + // We keep IPv4s and IPv6 separate and track the last one attempted so we can + // alternate the address family in the connection attempts. + ip4s := make([]netip.Addr, 0, 1) + ip6s := make([]netip.Addr, 0, 1) + var lastDialed netip.Addr + // Keep track of the lookup and dial errors separately. We prefer the dial errors + // when returning. + var lookupErr error + var dialErr error + // Channel to wait for before a new dial attempt. It starts + // with a closed channel that doesn't block because there's no + // wait initially. + var attemptDelayCh <-chan struct{} = newClosedChan() + type DialResult struct { + Conn StreamConn + Err error + } + dialCh := make(chan DialResult) + + // Channel that triggers when a new connection can be made. Starts blocked (nil) + // because we need IPs first. + var readyToDialCh <-chan struct{} = nil + // We keep track of pending operations (lookups and IPs to dial) so we can stop when + // there's no more work to wait for. + for opsPending := 1; opsPending > 0; { + if len(ip6s) == 0 && len(ip4s) == 0 { + // No IPs. Keep dial disabled. + readyToDialCh = nil + } else { + // There are IPs to dial. + if !lastDialed.IsValid() && len(ip6s) == 0 && resolutionCh != nil { + // Attempts haven't started and IPv6 lookup is not done yet. Set up Resolution Delay, as per + // https://datatracker.ietf.org/doc/html/rfc8305#section-8, if it hasn't been set up yet. + if readyToDialCh == nil { + resolutionDelayCtx, cancelResolutionDelay := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancelResolutionDelay() + readyToDialCh = resolutionDelayCtx.Done() + } + } else { + // Wait for the previous attempt. + readyToDialCh = attemptDelayCh + } + } + select { + // Receive lookup results. + case lookupRes, ok := <-resolutionCh: + if !ok { + opsPending-- + // Set to nil to make the read on lookupCh block and to signal lookup is done. + resolutionCh = nil + } + if lookupRes.Err != nil { + lookupErr = errors.Join(lookupErr, lookupRes.Err) + continue + } + opsPending += len(lookupRes.IPs) + // TODO: sort IPs as per https://datatracker.ietf.org/doc/html/rfc8305#section-4 + for _, ip := range lookupRes.IPs { + if ip.Is6() { + ip6s = append(ip6s, ip) + } else { + ip4s = append(ip4s, ip) + } + } + + // Wait for Connection Attempt Delay or attempt done. + // This case is disabled above when len(ip6s) == 0 && len(ip4s) == 0. + case <-readyToDialCh: + var toDial netip.Addr + // Alternate between IPv6 and IPv4. + if len(ip6s) == 0 || (lastDialed.Is6() && len(ip4s) > 0) { + toDial = ip4s[0] + ip4s = ip4s[1:] + } else { + toDial = ip6s[0] + ip6s = ip6s[1:] + } + // Reset Connection Attempt Delay, as per https://datatracker.ietf.org/doc/html/rfc8305#section-8 + // We don't tie the delay context to the parent because we don't want the readyToDialCh case + // to trigger on the parent cancellation. + delayCtx, cancelDelay := context.WithTimeout(context.Background(), 250*time.Millisecond) + attemptDelayCh = delayCtx.Done() + go func(addr string, cancelDelay context.CancelFunc) { + // Cancel the wait if the dial return early. + defer cancelDelay() + conn, err := d.dial(ctx, addr) + select { + case <-ctx.Done(): + if conn != nil { + conn.Close() + } + case dialCh <- DialResult{conn, err}: + } + }(net.JoinHostPort(toDial.String(), port), cancelDelay) + lastDialed = toDial + + // Receive dial result. + case dialRes := <-dialCh: + opsPending-- + if dialRes.Err != nil { + dialErr = errors.Join(dialErr, dialRes.Err) + continue + } + return dialRes.Conn, nil + + // Dial has been canceled. Return. + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if dialErr != nil { + return nil, dialErr + } + if lookupErr != nil { + return nil, lookupErr + } + return nil, errors.New("address lookup returned no IPs") +} diff --git a/transport/happyeyeballs_test.go b/transport/happyeyeballs_test.go new file mode 100644 index 00000000..b36711b2 --- /dev/null +++ b/transport/happyeyeballs_test.go @@ -0,0 +1,428 @@ +// Copyright 2024 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "context" + "errors" + "fmt" + "net/netip" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type collectStreamDialer struct { + Dialer StreamDialer + Addrs []string +} + +func (d *collectStreamDialer) DialStream(ctx context.Context, addr string) (StreamConn, error) { + d.Addrs = append(d.Addrs, addr) + return d.Dialer.DialStream(ctx, addr) +} + +var nilDialer = FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + return nil, nil +}) + +func newErrorStreamDialer(err error) StreamDialer { + return FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + return nil, err + }) +} + +func TestHappyEyeballsStreamDialer_DialStream(t *testing.T) { + t.Run("Works with IPv4 hosts", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{Dialer: &baseDialer} + _, err := dialer.DialStream(context.Background(), "8.8.8.8:53") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53"}, baseDialer.Addrs) + }) + + t.Run("Works with IPv6 hosts", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{Dialer: &baseDialer} + _, err := dialer.DialStream(context.Background(), "[2001:4860:4860::8888]:53") + require.NoError(t, err) + require.Equal(t, []string{"[2001:4860:4860::8888]:53"}, baseDialer.Addrs) + }) + + t.Run("Prefer IPv6", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: func(ctx context.Context, hostname string) <-chan HappyEyeballsResolution { + resultsCh := make(chan HappyEyeballsResolution, 2) + resultsCh <- HappyEyeballsResolution{[]netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil} + resultsCh <- HappyEyeballsResolution{[]netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil} + close(resultsCh) + return resultsCh + }, + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"[2001:4860:4860::8888]:53"}, baseDialer.Addrs) + }) + + t.Run("Prefer IPv6 if there's a small delay", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + time.Sleep(10 * time.Millisecond) + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"[2001:4860:4860::8888]:53"}, baseDialer.Addrs) + }) + + t.Run("Use IPv4 if IPv6 hangs, with fallback", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + baseDialer := collectStreamDialer{Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + if addr == "8.8.8.8:53" { + return nil, errors.New("failed to dial") + } + return nil, nil + })} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + // Make it hang. + <-ctx.Done() + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("8.8.4.4")}, nil + }, + ), + } + _, err := dialer.DialStream(ctx, "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53", "8.8.4.4:53"}, baseDialer.Addrs) + }) + + t.Run("Use IPv6 if IPv4 fails", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + time.Sleep(10 * time.Millisecond) + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return nil, errors.New("lookup failed") + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"[2001:4860:4860::8888]:53"}, baseDialer.Addrs) + }) + + t.Run("Use IPv4 if IPv6 fails", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return nil, errors.New("lookup failed") + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + time.Sleep(10 * time.Millisecond) + return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"8.8.8.8:53"}, baseDialer.Addrs) + }) + + t.Run("No dial if lookup fails", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return nil, errors.New("lookup failed") + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return nil, errors.New("lookup failed") + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.Error(t, err) + require.Empty(t, baseDialer.Addrs) + }) + + t.Run("No IPs returned", func(t *testing.T) { + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{}, nil + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.Error(t, err) + require.Empty(t, baseDialer.Addrs) + }) + + t.Run("Fallback to second address", func(t *testing.T) { + var dialedAddrs []string + dialer := HappyEyeballsStreamDialer{ + Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + dialedAddrs = append(dialedAddrs, addr) + if addr == "[2001:4860:4860::8888]:53" { + return nil, errors.New("dial failed") + } + return nil, nil + }), + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.NoError(t, err) + require.Equal(t, []string{"[2001:4860:4860::8888]:53", "8.8.8.8:53"}, dialedAddrs) + }) + + t.Run("IP order", func(t *testing.T) { + dialFailErr := errors.New("failed to dial") + baseDialer := collectStreamDialer{Dialer: newErrorStreamDialer(dialFailErr)} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("::1"), + netip.MustParseAddr("::2"), + netip.MustParseAddr("::3"), + }, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2.2.2.2"), + netip.MustParseAddr("3.3.3.3"), + }, nil + }, + ), + } + _, err := dialer.DialStream(context.Background(), "dns.google:53") + require.ErrorIs(t, err, dialFailErr) + require.Equal(t, []string{"[::1]:53", "1.1.1.1:53", "[::2]:53", "2.2.2.2:53", "[::3]:53", "3.3.3.3:53"}, baseDialer.Addrs) + }) + + t.Run("Cancelled lookups", func(t *testing.T) { + var hold sync.WaitGroup + hold.Add(1) + defer hold.Done() + ctx, cancel := context.WithCancel(context.Background()) + baseDialer := collectStreamDialer{Dialer: nilDialer} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + hold.Wait() + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + cancel() + hold.Wait() + return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil + }, + ), + } + _, err := dialer.DialStream(ctx, "dns.google:53") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, baseDialer.Addrs) + }) + + t.Run("Cancelled dial", func(t *testing.T) { + var hold sync.WaitGroup + hold.Add(1) + defer hold.Done() + ctx, cancel := context.WithCancel(context.Background()) + baseDialer := collectStreamDialer{Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + go cancel() + hold.Wait() + return nil, nil + })} + dialer := HappyEyeballsStreamDialer{ + Dialer: &baseDialer, + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888")}, nil + }, + func(ctx context.Context, host string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("8.8.8.8")}, nil + }, + ), + } + _, err := dialer.DialStream(ctx, "dns.google:53") + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, []string{"[2001:4860:4860::8888]:53"}, baseDialer.Addrs) + }) + + t.Run("Bad address", func(t *testing.T) { + dialer := HappyEyeballsStreamDialer{Dialer: nilDialer} + _, err := dialer.DialStream(context.Background(), "invalid address") + require.Error(t, err) + }) +} + +func ExampleNewParallelHappyEyeballsResolveFunc() { + ips := []netip.Addr{} + dialer := HappyEyeballsStreamDialer{ + Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + ips = append(ips, netip.MustParseAddrPort(addr).Addr()) + return nil, errors.New("not implemented") + }), + Resolve: NewParallelHappyEyeballsResolveFunc( + func(ctx context.Context, hostname string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("2001:4860:4860::8844"), + netip.MustParseAddr("2001:4860:4860::8888"), + }, nil + }, + func(ctx context.Context, hostname string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("8.8.4.4"), + }, nil + }, + ), + } + dialer.DialStream(context.Background(), "dns.google:53") + fmt.Println(ips) + // Output: + // [2001:4860:4860::8844 8.8.8.8 2001:4860:4860::8888 8.8.4.4] +} + +func ExampleHappyEyeballsStreamDialer_fixedResolution() { + ips := []netip.Addr{} + dialer := HappyEyeballsStreamDialer{ + Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + ips = append(ips, netip.MustParseAddrPort(addr).Addr()) + return nil, errors.New("not implemented") + }), + Resolve: func(ctx context.Context, hostname string) <-chan HappyEyeballsResolution { + resultCh := make(chan HappyEyeballsResolution, 1) + defer close(resultCh) + resultCh <- HappyEyeballsResolution{ + IPs: []netip.Addr{ + netip.MustParseAddr("2001:4860:4860::8844"), + netip.MustParseAddr("2001:4860:4860::8888"), + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("8.8.4.4"), + }, + Err: nil, + } + return resultCh + }, + } + dialer.DialStream(context.Background(), "dns.google:53") + fmt.Println(ips) + // Output: + // [2001:4860:4860::8844 8.8.8.8 2001:4860:4860::8888 8.8.4.4] +} + +func ExampleHappyEyeballsStreamDialer_dualStack() { + // Fixed resolutions to make the example work consistently without network access. + resolveIPv6 := func(ctx context.Context, hostname string) ([]netip.Addr, error) { + // Illustrative delay to show that IPv6 is preferred even if it arrives shortly + // after IPv4. + time.Sleep(10 * time.Millisecond) + return []netip.Addr{ + netip.MustParseAddr("2001:4860:4860::8844"), + netip.MustParseAddr("2001:4860:4860::8888"), + }, nil + } + resolveIPv4 := func(ctx context.Context, hostname string) ([]netip.Addr, error) { + return []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("8.8.4.4"), + }, nil + } + + ips := []netip.Addr{} + dialer := HappyEyeballsStreamDialer{ + Dialer: FuncStreamDialer(func(ctx context.Context, addr string) (StreamConn, error) { + ips = append(ips, netip.MustParseAddrPort(addr).Addr()) + return nil, errors.New("not implemented") + }), + + // This function mimics that created with NewParallelHappyEyeballsResolveFunc. + Resolve: func(ctx context.Context, hostname string) <-chan HappyEyeballsResolution { + // Use a buffered channel with space for both lookups, to ensure the goroutines won't + // block on channel write if the Happy Eyeballs algorithm is cancelled and no longer reading. + resultsCh := make(chan HappyEyeballsResolution, 2) + // Used to tell the IPv4 goroutine that the IPv6 one is done, so we can safely + // close resultsCh. + v6DoneCh := make(chan struct{}) + + // Run IPv6 resolution. + go func(hostname string) { + // Notify the IPv4 goroutine that the IPv6 is done. + defer close(v6DoneCh) + ips, err := resolveIPv6(ctx, hostname) + resultsCh <- HappyEyeballsResolution{ips, err} + }(hostname) + + // Run IPv4 resolution. + go func(hostname string) { + ips, err := resolveIPv4(ctx, hostname) + resultsCh <- HappyEyeballsResolution{ips, err} + // Wait for the IPv6 resolution before closing the channel. + <-v6DoneCh + close(resultsCh) + }(hostname) + + // Return the channel quickly, before resolutions are done. + return resultsCh + }, + } + dialer.DialStream(context.Background(), "dns.google:53") + fmt.Println(ips) + // Output: + // [2001:4860:4860::8844 8.8.8.8 2001:4860:4860::8888 8.8.4.4] +} diff --git a/transport/packet.go b/transport/packet.go index bac37b3d..13ac4141 100644 --- a/transport/packet.go +++ b/transport/packet.go @@ -155,7 +155,7 @@ type PacketListener interface { // UDPListener is a [PacketListener] that uses the standard [net.ListenConfig].ListenPacket to listen. type UDPListener struct { net.ListenConfig - // The local address to bind to, as specified in [net.ListenPacket]. + // The local address to bind to, as specified in net.ListenPacket. Address string }