net-merge distributes TCP flows across usable adapters with net.Dialer Control hooks. A walk through socket binding, errno policy, health probes, and its IPv6 fallback.
· 8 min read

How net-merge Load Balances TCP Flows Across Network Adapters in Go


There is no net.Dialer.Interface field in Go. If you want a socket to leave through a particular adapter, you get Control func(network, address string, c syscall.RawConn) error, a hook that fires after the socket exists but before it connects, and you set the platform’s socket option yourself.

net-merge builds a bandwidth-aggregation tool on that hook. It distributes TCP and UDP flows across the usable adapters it discovers—such as Wi-Fi, Ethernet, or a phone tether—so multi-connection traffic can use more than one uplink.

The aggregation is the headline, but the reusable parts are underneath: per-connection interface pinning, errno classification that works on Windows and POSIX without build tags, and byte accounting done by wrapping net.TCPConn.

Per-flow balancing, not per-packet

net-merge balances flows. A single TCP connection always leaves through one adapter, start to finish.

This is not a simplification, it’s forced. Stripe packets of one connection across two uplinks with different public IPs and the remote server sees traffic arriving from two sources with one sequence space. It drops them.

So the unit of scheduling is a connection. Ten download connections land on different adapters; one connection gets one link’s speed. That is why the tool pairs well with multi-connection downloaders and why a single curl of a large file gains nothing. If you were hoping for the latter, stop reading the code and go read about MPTCP instead.

Scheduling lives in internal/engine/sched.go; the dial path that consumes it is internal/engine/balancer.go. The Balancer struct is four fields:

type Balancer struct {
	S              *Scheduler
	ConnectTimeout time.Duration
	FailCooldown   time.Duration
	v6mode         atomic.Int32
}

v6mode is an atomic.Int32 rather than a mutex-guarded field. The IPv6 mode gets read on every new flow and written almost never, from the tray menu or a CLI flag, so an atomic load is the right trade. The exported accessors handle the conversion:

func (b *Balancer) SetV6Mode(m V6Mode) { b.v6mode.Store(int32(m)) }
func (b *Balancer) V6Mode() V6Mode     { return V6Mode(b.v6mode.Load()) }

This is a compact pattern for a typed enum that changes rarely but is read on every flow: store the underlying integer atomically and convert at the API boundary.

Pinning a socket to an adapter

Binding goes through nics.ForceEgress(index int, v6 bool), which returns a Control function. The implementation splits per platform across internal/nics/egress_linux.go, internal/nics/egress_darwin.go, and internal/nics/egress_windows.go. Linux has SO_BINDTODEVICE; Windows and macOS use interface-index options. Three different syscalls, one signature, build tags to pick.

internal/engine/probe.go shows the wiring:

d := net.Dialer{
	LocalAddr: &net.TCPAddr{IP: ip},
	Control:   nics.ForceEgress(sl.Index, f == Fam6),
	Timeout:   probeTimeout,
}
if c, err := d.Dial(network, t); err == nil {
	c.Close()
	return true
}

The two settings serve different purposes. LocalAddr selects the source address, while Control applies the platform-specific interface binding before connect. Selecting a source address alone does not generally express the same routing constraint, and the exact routing result is operating-system dependent.

UDP gets the same treatment in internal/engine/flowconn.go, through net.ListenConfig instead of net.Dialer:

lc := net.ListenConfig{Control: nics.ForceEgress(sl.Index, f == Fam6)}
pc, err := lc.ListenPacket(ctx, network, net.JoinHostPort(sl.ip(f).String(), "0"))

net.ListenConfig is the overlooked sibling of net.Dialer. It exists for exactly this, setting socket options on listening and packet sockets, and it takes a context.Context for the listen call.

Classifying dial errors across Windows and POSIX

A failed dial raises two questions. Retry on another adapter? And is this adapter dead?

The answer lives in the errno, and Windows makes that annoying. The syscall.E* constants there don’t carry WSA values, so balancer.go declares them:

const (
	wsaeAddrNotAvail syscall.Errno = 10049
	wsaeNetDown      syscall.Errno = 10050
	wsaeNetUnreach   syscall.Errno = 10051
	wsaeTimedOut     syscall.Errno = 10060
	wsaeConnRefused  syscall.Errno = 10061
	wsaeHostUnreach  syscall.Errno = 10065
)

classify maps an error to a small struct of decisions:

type dialClass struct {
	rep      byte
	retry    bool // worth trying another NIC
	linkDown bool // this NIC itself is broken: sideline it
}

Matching runs through errors.Is and errors.As, with a closure that checks both codes at once:

is := func(wsa, std syscall.Errno) bool { return errors.Is(err, wsa) || errors.Is(err, std) }

errors.Is unwraps through *net.OpError and *os.SyscallError down to the syscall.Errno, so one expression covers every platform and the file needs no build tag. The distinctions then drive net-merge’s policy: it treats ECONNREFUSED as a destination-level refusal and does not retry another adapter, while ENETUNREACH triggers a retry and temporarily sidelines that adapter. Those errno values are useful signals, not proofs—the reachability failure may be destination-specific—so the cooldown is a heuristic rather than a diagnosis of the physical link.

classify also pulls *net.DNSError out with errors.As and handles timeouts through the net.Error interface:

case errors.Is(err, context.DeadlineExceeded), errors.As(err, &ne) && ne.Timeout():
	return dialClass{rep: repHostUnreachable, retry: true}

Counting bytes by embedding net.TCPConn

Weighting adapters by measured throughput requires per-connection byte counts. internal/engine/flowconn.go gets them by embedding:

type slotConn struct {
	*net.TCPConn
	sl   *Slot
	fam  Family
	s    *Scheduler
	once sync.Once
}

func (c *slotConn) Read(p []byte) (int, error) {
	n, err := c.TCPConn.Read(p)
	c.sl.bytesIn[c.fam].Add(int64(n))
	return n, err
}

Embedding *net.TCPConn instead of net.Conn is a deliberate choice. It keeps CloseRead and CloseWrite on the surface, which the TUN relay needs to half-close each direction independently.

The release logic carries its own explanation:

// release frees the slot's balancing count. It happens at the first half-close, not at the final
// Close: the relay can keep a finished flow open for up to a minute waiting for the peer's FIN,
// and counting those as active would steer new flows away from the NIC that just finished.
func (c *slotConn) release() { c.once.Do(func() { c.s.ReleaseFor(c.sl, c.fam) }) }

That comment is the shape of a bug someone watched happen. A fast adapter finishes its downloads, the connections sit in FIN-WAIT for a minute, the scheduler still counts them as active, and new flows get routed away from the only adapter that’s free. The sync.Once is there because CloseRead, CloseWrite and Close can all reach release. Without it the active-flow count goes negative and the weighting inverts.

UDP skips the whole mechanism. The comment in ListenUDP makes the case: UDP flows are short and there are a lot of them, DNS being the obvious offender, so holding a balancing slot for each one is pure overhead. The scheduler releases the slot as soon as the socket exists and lets tie-breaking scatter them.

Health probes with per-family state

A tethered phone with an expired data plan has an IP, a route, and no connectivity. The routing table thinks it’s fine. Every connection through it fails.

internal/engine/probe.go catches that by dialing well-known anycast addresses through each adapter:

var probeTargets = [2][]string{
	Fam4: {"1.1.1.1:443", "8.8.8.8:443"},
	Fam6: {"[2606:4700:4700::1111]:443", "[2001:4860:4860::8888]:443"},
}

Using the Family constants as array indices is a small thing that reads nicely: probeTargets[f], no map lookup, no allocation, and the compiler verifies the literal covers both slots.

The probe loop ticks every second and adapts its intervals. New adapters are scheduled on the first tick, healthy ones every 15 seconds, and failed ones every 4 seconds so they can rejoin quickly. Dispatch keeps the critical section small:

for _, j := range s.probeDue() {
	go func() {
		defer j.sl.probing[j.f].Store(false)
		s.SetState(j.sl, j.f, probe(j.sl, j.f))
	}()
}

probeDue takes the mutex, collects work, marks each (slot, family) pair in flight with an atomic bool, and returns. The dialing, which can block for two seconds on a timeout, happens outside the lock in its own goroutine. The in-flight flag stops the ticker from stacking up duplicate probes against a slot that’s already waiting.

IPv6: the dual-stack trap

Here’s the failure mode that makes aggregation quietly useless. Your Ethernet has IPv6. Your phone tether doesn’t. Applications prefer IPv6, so every flow goes to the one adapter that can carry it, and your aggregated link is now a single link with extra steps.

V6Auto refuses IPv6 flows unless every adapter can do IPv6:

func (b *Balancer) V6Allowed() bool {
	switch b.V6Mode() {
	case V6Off:
		return false
	case V6On:
		return b.S.V6Usable()
	}
	return b.S.V6CoversAll()
}

For clients that implement Happy Eyeballs, refusing the IPv6 attempt promptly lets the concurrent or subsequent IPv4 attempt win and restores the wider adapter pool. A client making an IPv6-only connection will simply fail, so this is a compatibility tradeoff rather than a transparent fallback for every application.

The CLI surface

cmd/net-merge/main.go uses the standard library flag package with a FlagSet per subcommand. The repeatable -weight "Wi-Fi=3" flag implements flag.Value:

type weightFlags map[string]float64

func (w weightFlags) String() string { return "" }
func (w weightFlags) Set(v string) error {
	i := strings.LastIndex(v, "=")
	if i < 1 {
		return fmt.Errorf(`want "Name=3", got %q`, v)
	}
	n, err := strconv.ParseFloat(v[i+1:], 64)
	if err != nil || n <= 0 {
		return fmt.Errorf("weight must be > 0 in %q", v)
	}
	w[v[:i]] = n
	return nil
}

Implementing flag.Value on a map type gives you a repeatable key=value flag with no extra dependency. Set fires once per occurrence and the map accumulates. strings.LastIndex also permits an = inside the adapter name by treating only the final separator as the start of the numeric weight.

There’s a -pprof flag too, and cmd/net-merge/pprof.go rejects anything that isn’t loopback:

if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() {
	return fmt.Errorf("-pprof must be a loopback address like 127.0.0.1:6060, got %q", addr)
}

This loopback check is important when net/http/pprof is enabled in a process that may run with elevated privileges. The blank import registers handlers on the default mux, and those endpoints do not add authentication on their own.

Where the platform split lives

internal/nics is the tidiest build-tag layout in the tree. Three concerns, three files each: counters_{linux,darwin,windows}.go for throughput sampling, egress_*.go for socket binding, info_*.go for adapter discovery. Shared types and the DiscoverNicsDetailed entry point live in nics.go. No runtime switch runtime.GOOS anywhere, no interface indirection for its own sake.

internal/platform follows the same shape for autostart (autostart_windows.go, autostart_linux.go, autostart_darwin.go) and privilege elevation. The TUN device comes from Wintun on Windows and the native driver on Linux and macOS. The engine never asks which; it calls Balancer.DialPinned and gets a connection.

If you’re writing Go that touches network interfaces, internal/engine/balancer.go is a useful example of keeping retry policy separate from platform socket options, while internal/engine/flowconn.go shows how connection wrappers preserve half-close methods and account for bytes. The important caveat is that the errno and IPv6 choices are application policy, not universal networking rules.