Repository: cilium/cilium
Pull request #39012: loadbalancer: Implement UDP socket termination
Author: login: joamaki
Labels: name: release-note/misc
description: This PR makes changes that have no direct user impact.
Created: 2025-04-17T13:33:48Z | Merged: 2025-05-14T07:50:45Z

This ports the `pkg/service/connections.go` to the new control-plane. 


Diff:
diff --git a/pkg/loadbalancer/backend.go b/pkg/loadbalancer/backend.go
index d6aa9c88691cf..fe6f5f04ce2e7 100644
--- a/pkg/loadbalancer/backend.go
+++ b/pkg/loadbalancer/backend.go
@@ -124,6 +124,21 @@ func (be *Backend) GetInstanceFromSource(name ServiceName, src source.Source) *B
 	return nil
 }
 
+// IsAlive returns true if any of the instances are marked active or terminating and healthy.
+// This signals whether the backend should still be considered alive or not for the purposes
+// of terminating connections to it.
+func (be *Backend) IsAlive() bool {
+	for _, inst := range be.Instances.All() {
+		switch {
+		case inst.Unhealthy:
+			continue
+		case inst.State == BackendStateActive, inst.State == BackendStateTerminating:
+			return true
+		}
+	}
+	return false
+}
+
 func (be *Backend) String() string {
 	return strings.Join(be.TableRow(), " ")
 }
diff --git a/pkg/loadbalancer/config.go b/pkg/loadbalancer/config.go
index 8d95b3c4907d3..2c4667a6d6ae5 100644
--- a/pkg/loadbalancer/config.go
+++ b/pkg/loadbalancer/config.go
@@ -374,17 +374,26 @@ func (def TestConfig) Flags(flags *pflag.FlagSet) {
 type ExternalConfig struct {
 	ZoneMapper
 
-	EnableIPv4, EnableIPv6 bool
-	KubeProxyReplacement   bool
+	EnableIPv4, EnableIPv6                 bool
+	KubeProxyReplacement                   bool
+	NodePortMin, NodePortMax               uint16
+	NodePortAlg                            string
+	LoadBalancerAlgorithmAnnotation        bool
+	BPFSocketLBHostnsOnly                  bool
+	EnableSocketLB                         bool
+	EnableSocketLBPodConnectionTermination bool
 }
 
 // NewExternalConfig maps the daemon config to [ExternalConfig].
 func NewExternalConfig(cfg *option.DaemonConfig) ExternalConfig {
 	return ExternalConfig{
-		ZoneMapper:           cfg,
-		EnableIPv4:           cfg.EnableIPv4,
-		EnableIPv6:           cfg.EnableIPv6,
-		KubeProxyReplacement: cfg.KubeProxyReplacement == option.KubeProxyReplacementTrue || cfg.EnableNodePort,
+		ZoneMapper:                             cfg,
+		EnableIPv4:                             cfg.EnableIPv4,
+		EnableIPv6:                             cfg.EnableIPv6,
+		KubeProxyReplacement:                   cfg.KubeProxyReplacement == option.KubeProxyReplacementTrue || cfg.EnableNodePort,
+		BPFSocketLBHostnsOnly:                  cfg.BPFSocketLBHostnsOnly,
+		EnableSocketLB:                         cfg.EnableSocketLB,
+		EnableSocketLBPodConnectionTermination: cfg.EnableSocketLBPodConnectionTermination,
 	}
 }
 
diff --git a/pkg/loadbalancer/maps/lbmaps.go b/pkg/loadbalancer/maps/lbmaps.go
index feef860cdbc39..6418473b969ba 100644
--- a/pkg/loadbalancer/maps/lbmaps.go
+++ b/pkg/loadbalancer/maps/lbmaps.go
@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"log/slog"
 	"math/rand/v2"
+	"net"
 	"os"
 	"reflect"
 	"unsafe"
@@ -18,7 +19,6 @@ import (
 	"github.com/cilium/cilium/pkg/bpf"
 	"github.com/cilium/cilium/pkg/byteorder"
 	"github.com/cilium/cilium/pkg/loadbalancer"
-	"github.com/cilium/cilium/pkg/loadbalancer/writer"
 	"github.com/cilium/cilium/pkg/lock"
 	"github.com/cilium/cilium/pkg/logging/logfields"
 	"github.com/cilium/cilium/pkg/maglev"
@@ -34,11 +34,10 @@ type lbmapsParams struct {
 	MaglevConfig maglev.Config
 	Config       loadbalancer.Config
 	ExtConfig    loadbalancer.ExternalConfig
-	Writer       *writer.Writer
 }
 
 func newLBMaps(p lbmapsParams) bpf.MapOut[LBMaps] {
-	if !p.Writer.IsEnabled() {
+	if !p.Config.EnableExperimentalLB {
 		return bpf.MapOut[LBMaps]{}
 	}
 
@@ -103,6 +102,12 @@ type maglevMaps interface {
 	DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOuterVal, lbmap.MaglevInnerKey, *lbmap.MaglevInnerVal, bool)) error
 }
 
+type sockRevNatMaps interface {
+	UpdateSockRevNat(cookie uint64, addr net.IP, port uint16, revNatIndex uint16) error
+	DeleteSockRevNat(cookie uint64, addr net.IP, port uint16) error
+	ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool
+}
+
 // LBMaps defines the map operations performed by the reconciliation.
 // Depending on this interface instead of on the underlying maps allows
 // testing the implementation with a fake map or injected errors.
@@ -113,6 +118,7 @@ type LBMaps interface {
 	affinityMaps
 	sourceRangeMaps
 	maglevMaps
+	sockRevNatMaps
 
 	IsEmpty() bool
 }
@@ -130,9 +136,11 @@ type BPFLBMaps struct {
 	backend4Map, backend6Map         *bpf.Map
 	revNat4Map, revNat6Map           *bpf.Map
 	affinityMatchMap                 *bpf.Map
+	sockRevNat4Map, sockRevNat6Map   *bpf.Map
 	sourceRange4Map, sourceRange6Map *bpf.Map
 	maglev4Map, maglev6Map           *bpf.Map // Inner maps are referenced inside maglev4Map and maglev6Map and can be retrieved by lbmap.MaglevInnerMapFromID.
-	maglevInnerMapSpec               *ebpf.MapSpec
+
+	maglevInnerMapSpec *ebpf.MapSpec
 }
 
 //
@@ -238,6 +246,28 @@ func newSourceRange6Map(maxEntries int) *bpf.Map {
 	)
 }
 
+func newSockRevNat4Map(maxEntries int) *bpf.Map {
+	return bpf.NewMap(
+		lbmap.SockRevNat4MapName,
+		ebpf.LRUHash,
+		&lbmap.SockRevNat4Key{},
+		&lbmap.SockRevNat4Value{},
+		maxEntries,
+		0,
+	)
+}
+
+func newSockRevNat6Map(maxEntries int) *bpf.Map {
+	return bpf.NewMap(
+		lbmap.SockRevNat6MapName,
+		ebpf.LRUHash,
+		&lbmap.SockRevNat6Key{},
+		&lbmap.SockRevNat6Value{},
+		maxEntries,
+		0,
+	)
+}
+
 func newMaglevOuterMap(name string, maxEntries int, innerSpec *ebpf.MapSpec) *bpf.Map {
 	return bpf.NewMapWithInnerSpec(
 		name,
@@ -269,6 +299,7 @@ func (r *BPFLBMaps) allMaps() ([]mapDesc, []mapDesc) {
 		{&r.revNat4Map, newRevNat4Map, r.Cfg.LBRevNatEntries},
 		{&r.sourceRange4Map, newSourceRange4Map, r.Cfg.LBSourceRangeMapEntries},
 		{&r.maglev4Map, newMaglev4, r.Cfg.LBMaglevMapEntries},
+		{&r.sockRevNat4Map, newSockRevNat4Map, r.Cfg.LBSockRevNatEntries},
 	}
 	v6Maps := []mapDesc{
 		{&r.service6Map, newService6Map, r.Cfg.LBServiceMapEntries},
@@ -276,6 +307,7 @@ func (r *BPFLBMaps) allMaps() ([]mapDesc, []mapDesc) {
 		{&r.revNat6Map, newRevNat6Map, r.Cfg.LBRevNatEntries},
 		{&r.sourceRange6Map, newSourceRange6Map, r.Cfg.LBSourceRangeMapEntries},
 		{&r.maglev6Map, newMaglev6, r.Cfg.LBMaglevMapEntries},
+		{&r.sockRevNat6Map, newSockRevNat6Map, r.Cfg.LBSockRevNatEntries},
 	}
 	mapsToCreate := []mapDesc{
 		{&r.affinityMatchMap, newAffinityMatchMap, r.Cfg.LBAffinityMapEntries},
@@ -596,6 +628,57 @@ func (r *BPFLBMaps) DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOuterVa
 	return errors.Join(errs...)
 }
 
+// DeleteSockRevNat implements LBMaps.
+func (r *BPFLBMaps) DeleteSockRevNat(cookie uint64, addr net.IP, port uint16) error {
+	if addr.To4() != nil && r.sockRevNat4Map != nil {
+		key := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		_, err := r.sockRevNat4Map.SilentDelete(key)
+		return err
+	} else if r.sockRevNat6Map != nil {
+		key := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		_, err := r.sockRevNat6Map.SilentDelete(key)
+		return err
+	}
+	return nil
+}
+
+// UpdateSockRevNat implements LBMaps.
+func (r *BPFLBMaps) UpdateSockRevNat(cookie uint64, addr net.IP, port uint16, revNatIndex uint16) error {
+	if addr.To4() != nil && r.sockRevNat4Map != nil {
+		key := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		value := lbmap.SockRevNat4Value{
+			Address:     key.Address,
+			Port:        key.Port,
+			RevNatIndex: revNatIndex,
+		}
+		return r.sockRevNat4Map.Update(key, &value)
+	} else if r.sockRevNat6Map != nil {
+		key := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		value := lbmap.SockRevNat6Value{
+			Address:     key.Address,
+			Port:        key.Port,
+			RevNatIndex: revNatIndex,
+		}
+		return r.sockRevNat6Map.Update(key, &value)
+	}
+	return nil
+}
+
+func (r *BPFLBMaps) ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool {
+	if addr.To4() != nil && r.sockRevNat4Map != nil {
+		key := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		if v, _ := r.sockRevNat4Map.Lookup(key); v != nil {
+			return true
+		}
+	} else if r.sockRevNat6Map != nil {
+		key := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		if v, _ := r.sockRevNat6Map.Lookup(key); v != nil {
+			return true
+		}
+	}
+	return false
+}
+
 // IsEmpty implements lbmaps.
 func (r *BPFLBMaps) IsEmpty() bool {
 	mapIsEmpty := func(m *bpf.Map) bool {
@@ -605,17 +688,13 @@ func (r *BPFLBMaps) IsEmpty() bool {
 		var key []byte
 		return errors.Is(m.NextKey(nil, &key), ebpf.ErrKeyNotExist)
 	}
-	return mapIsEmpty(r.service4Map) &&
-		mapIsEmpty(r.service6Map) &&
-		mapIsEmpty(r.backend4Map) &&
-		mapIsEmpty(r.backend6Map) &&
-		mapIsEmpty(r.revNat4Map) &&
-		mapIsEmpty(r.revNat6Map) &&
-		mapIsEmpty(r.affinityMatchMap) &&
-		mapIsEmpty(r.sourceRange4Map) &&
-		mapIsEmpty(r.sourceRange6Map) &&
-		mapIsEmpty(r.maglev4Map) &&
-		mapIsEmpty(r.maglev6Map)
+	createdMaps, _ := r.allMaps()
+	for _, desc := range createdMaps {
+		if !mapIsEmpty(*desc.target) {
+			return false
+		}
+	}
+	return true
 }
 
 var _ LBMaps = &BPFLBMaps{}
@@ -627,6 +706,22 @@ type FaultyLBMaps struct {
 	failureProbability float32
 }
 
+// DeleteSockRevNat implements LBMaps.
+func (f *FaultyLBMaps) DeleteSockRevNat(cookie uint64, addr net.IP, port uint16) error {
+	if f.isFaulty() {
+		return errFaulty
+	}
+	return f.impl.DeleteSockRevNat(cookie, addr, port)
+}
+
+// UpdateSockRevNat implements LBMaps.
+func (f *FaultyLBMaps) UpdateSockRevNat(cookie uint64, addr net.IP, port uint16, revNatIndex uint16) error {
+	if f.isFaulty() {
+		return errFaulty
+	}
+	return f.impl.UpdateSockRevNat(cookie, addr, port, revNatIndex)
+}
+
 // DeleteSourceRange implements lbmaps.
 func (f *FaultyLBMaps) DeleteSourceRange(key lbmap.SourceRangeKey) error {
 	if f.isFaulty() {
@@ -764,6 +859,10 @@ func (f *FaultyLBMaps) DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOute
 	return f.impl.DumpMaglev(cb)
 }
 
+func (f *FaultyLBMaps) ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool {
+	return f.impl.ExistsSockRevNat(cookie, addr, port)
+}
+
 func (f *FaultyLBMaps) isFaulty() bool {
 	// Float32() returns value between [0.0, 1.0).
 	// We fail if the value is less than our probability [0.0, 1.0].
@@ -791,6 +890,11 @@ func (fm *fakeBPFMap) update(key bpf.MapKey, value any) error {
 	return nil
 }
 
+func (fm *fakeBPFMap) exists(key bpf.MapKey) bool {
+	_, exists := fm.Map.Load(bpfKey(key))
+	return exists
+}
+
 func (fm *fakeBPFMap) IsEmpty() bool {
 	return fm.Map.IsEmpty()
 }
@@ -810,15 +914,16 @@ func dumpFakeBPFMap[K any, V any](m *fakeBPFMap, cb func(K, V)) {
 }
 
 type FakeLBMaps struct {
-	aff      fakeBPFMap
-	be       fakeBPFMap
-	svc      fakeBPFMap
-	revNat   fakeBPFMap
-	srcRange fakeBPFMap
-	mglv4    fakeBPFMap
-	mglv6    fakeBPFMap
-	inners   lock.Map[uint32, *fakeBPFMap]
-	nextID   uint32
+	aff        fakeBPFMap
+	be         fakeBPFMap
+	svc        fakeBPFMap
+	revNat     fakeBPFMap
+	sockRevNat fakeBPFMap
+	srcRange   fakeBPFMap
+	mglv4      fakeBPFMap
+	mglv6      fakeBPFMap
+	inners     lock.Map[uint32, *fakeBPFMap]
+	nextID     uint32
 }
 
 func NewFakeLBMaps() LBMaps {
@@ -965,6 +1070,56 @@ func (f *FakeLBMaps) DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOuterV
 	return err
 }
 
+// DeleteSockRevNat implements LBMaps.
+func (f *FakeLBMaps) DeleteSockRevNat(cookie uint64, addr net.IP, port uint16) error {
+	var key bpf.MapKey
+	if addr.To4() != nil {
+		key4 := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		key = key4
+	} else {
+		key6 := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		key = key6
+	}
+	return f.sockRevNat.delete(key)
+}
+
+// UpdateSockRevNat implements LBMaps.
+func (f *FakeLBMaps) UpdateSockRevNat(cookie uint64, addr net.IP, port uint16, revNatIndex uint16) error {
+	var key bpf.MapKey
+	var value bpf.MapValue
+	if addr.To4() != nil {
+		key4 := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		key = key4
+		value = &lbmap.SockRevNat4Value{
+			Address:     key4.Address,
+			Port:        key4.Port,
+			RevNatIndex: revNatIndex,
+		}
+	} else {
+		key6 := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		key = key6
+		value = &lbmap.SockRevNat6Value{
+			Address:     key6.Address,
+			Port:        key6.Port,
+			RevNatIndex: revNatIndex,
+		}
+	}
+	f.sockRevNat.update(key, value)
+	return nil
+}
+
+func (f *FakeLBMaps) ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool {
+	var key bpf.MapKey
+	if addr.To4() != nil {
+		key4 := lbmap.NewSockRevNat4Key(cookie, addr, port)
+		key = key4
+	} else {
+		key6 := lbmap.NewSockRevNat6Key(cookie, addr, port)
+		key = key6
+	}
+	return f.sockRevNat.exists(key)
+}
+
 // IsEmpty implements lbmaps.
 func (f *FakeLBMaps) IsEmpty() bool {
 	return f.aff.IsEmpty() &&
diff --git a/pkg/loadbalancer/reconciler/cell.go b/pkg/loadbalancer/reconciler/cell.go
index befc8538e3677..e97193f2d1167 100644
--- a/pkg/loadbalancer/reconciler/cell.go
+++ b/pkg/loadbalancer/reconciler/cell.go
@@ -28,4 +28,7 @@ var Cell = cell.Module(
 
 	// Provide the 'lb/' script commands for debugging and testing.
 	cell.Provide(scriptCommands),
+
+	// Terminate sockets connected to backends that have been removed.
+	SocketTerminationCell,
 )
diff --git a/pkg/loadbalancer/reconciler/termination.go b/pkg/loadbalancer/reconciler/termination.go
new file mode 100644
index 0000000000000..569f25fc5aff2
--- /dev/null
+++ b/pkg/loadbalancer/reconciler/termination.go
@@ -0,0 +1,256 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			if backend.Address.L4Addr.Protocol != lb.UDP {
+				continue
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
+				if !opSupported {
+					// The kernel doesn't support socket termination. We can stop processing.
+					p.Log.Error("Forcefully terminating sockets connected to deleted service backends " +
+						"not supported by underlying kernel: see kube-proxy free guide for " +
+						"the required kernel configurations")
+					return nil
+				}
+			}
+		}
+
+		select {
+		case <-watch:
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+		if err := limiter.Wait(ctx); err != nil {
+			return err
+		}
+	}
+}
+
+// terminateUDPConnectionsToBackend closes UDP connection sockets that match the destination
+// l3/l4 tuple addr that also are tracked in the sock rev nat map (including socket cookie).
+func terminateUDPConnectionsToBackend(p socketTerminationParams, l3n4Addr lb.L3n4Addr) (opSupported bool) {
+	opSupported = true
+
+	var (
+		family   uint8
+		protocol uint8
+	)
+	ip := net.IP(l3n4Addr.AddrCluster.Addr().AsSlice())
+	l4Addr := l3n4Addr.L4Addr
+
+	switch l3n4Addr.Protocol {
+	case lb.UDP:
+		protocol = unix.IPPROTO_UDP
+	default:
+		return
+	}
+	p.Log.Debug("Terminating sockets connected to deleted backend", logfields.Deleted, l3n4Addr)
+	if l3n4Addr.IsIPv6() {
+		family = syscall.AF_INET6
+	} else {
+		family = syscall.AF_INET
+	}
+	// Filter pod connections load-balanced to the passed service backend.
+	//
+	// When pod traffic is load-balanced to service backends, the cilium datapath
+	// records entries in the sock rev nat map that store the pod socket cookie
+	// (unique identifier in the kernel) along with the destination backend ip/port.
+	checkSockInRevNat := func(id netlink.SocketID) bool {
+		cookie := uint64(id.Cookie[1])
+		cookie = cookie<<32 + uint64(id.Cookie[0])
+		return p.LBMaps.ExistsSockRevNat(cookie, id.Destination, id.DestinationPort)
+	}
+
+	destroy := func(nsName string, ns *netns.NetNS) error {
+		err := p.NetNSOps.do(ns, func() error {
+			return p.SocketDestroyer.Destroy(sockets.SocketFilter{
+				Family:    family,
+				Protocol:  protocol,
+				DestIp:    ip,
+				DestPort:  l4Addr.Port,
+				DestroyCB: checkSockInRevNat,
+			})
+		})
+
+		if err != nil {
+			if errors.Is(err, unix.EOPNOTSUPP) {
+				opSupported = false
+				return err
+			} else {
+				p.Log.Error(
+					"error while forcefully terminating sockets connected to "+
+						"deleted service backend. Consider restarting any application pods sending traffic "+
+						"to the backend",
+					logfields.Error, err,
+					logfields.L3n4Addr, l3n4Addr,
+					logfields.NetNSName, nsName,
+				)
+			}
+		}
+		return nil
+	}
+
+	// Terminate sockets in host namespace
+	if hostNS, err := p.NetNSOps.current(); err == nil {
+		destroy("<host>", hostNS)
+	}
+
+	// Iterate over all pod network namespaces, and terminate any stale connections.
+	if p.ExtConfig.EnableSocketLBPodConnectionTermination && !p.ExtConfig.BPFSocketLBHostnsOnly {
+		iter, errs := p.NetNSOps.all()
+		for name, ns := range iter {
+			destroy(name, ns)
+		}
+		for err := range errs {
+			p.Log.Debug("Error opening netns, skipping",
+				logfields.Error, err)
+		}
+	}
+
+	return
+}
diff --git a/pkg/loadbalancer/reconciler/termination_test.go b/pkg/loadbalancer/reconciler/termination_test.go
new file mode 100644
index 0000000000000..b79309af381b8
--- /dev/null
+++ b/pkg/loadbalancer/reconciler/termination_test.go
@@ -0,0 +1,444 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"fmt"
+	"iter"
+	"log/slog"
+	"maps"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/vishvananda/netlink"
+	"go.uber.org/goleak"
+	"golang.org/x/sys/unix"
+
+	cmtypes "github.com/cilium/cilium/pkg/clustermesh/types"
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/option"
+	"github.com/cilium/cilium/pkg/source"
+	"github.com/cilium/cilium/pkg/testutils"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+func TestSocketTermination_ControlPlane(t *testing.T) {
+	testutils.PrivilegedTest(t)
+
+	for _, hostOnly := range []bool{true, false} {
+		t.Run(fmt.Sprintf("hostOnly=%v", hostOnly), func(t *testing.T) {
+			testSocketTermination(t, hostOnly)
+		})
+	}
+}
+
+func testSocketTermination(t *testing.T, hostOnly bool) {
+	var beAddr loadbalancer.L3n4Addr
+	require.NoError(t, beAddr.ParseFromString("1.0.0.1:80/UDP"))
+
+	var (
+		db       *statedb.DB
+		backends statedb.RWTable[*loadbalancer.Backend]
+	)
+	mock := &mockDestroyer{
+		requests: make(chan sockets.SocketFilter, 10),
+	}
+
+	syncChan := make(testSyncChan)
+
+	visitedNamespaces := []*netns.NetNS{}
+	hostNS := &netns.NetNS{}
+	fooNS := &netns.NetNS{}
+
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+		loadbalancer.ConfigCell,
+
+		cell.Provide(
+			loadbalancer.NewBackendsTable,
+			statedb.RWTable[*loadbalancer.Backend].ToTable,
+			func() sockets.SocketDestroyer { return mock },
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+			func() testSyncChan { return syncChan },
+			func() *option.DaemonConfig {
+				return &option.DaemonConfig{
+					BPFSocketLBHostnsOnly:                  hostOnly,
+					EnableSocketLB:                         true,
+					EnableSocketLBPodConnectionTermination: true,
+					EnableIPv4:                             true,
+					EnableIPv6:                             true,
+				}
+			},
+			func() netnsOps {
+				return netnsOps{
+					current: func() (*netns.NetNS, error) {
+						return hostNS, nil
+					},
+					do: func(ns *netns.NetNS, f func() error) error {
+						visitedNamespaces = append(visitedNamespaces, ns)
+						return f()
+					},
+					all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+						errs := make(chan error)
+						close(errs)
+						return maps.All(map[string]*netns.NetNS{
+							"foo": fooNS,
+						}), errs
+					},
+				}
+			},
+		),
+		cell.Module("test", "test",
+			cell.Invoke(registerSocketTermination),
+		),
+		cell.Invoke(func(db_ *statedb.DB, backends_ statedb.RWTable[*loadbalancer.Backend]) {
+			db = db_
+			backends = backends_
+
+		}),
+	)
+
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Add a backends and wait for the job to pick it up
+	wtxn := db.WriteTxn(backends)
+	backends.Insert(wtxn, &loadbalancer.Backend{Address: beAddr})
+	wtxn.Commit()
+
+	// Wait until the first change has been seen
+	<-syncChan
+
+	wtxn = db.WriteTxn(backends)
+	backends.DeleteAll(wtxn)
+	wtxn.Commit()
+
+	// We should see two deletions: one for host ns (if enabled) and one for the mocked
+	// "foo" one.
+	filter := <-mock.requests
+	require.True(t, beAddr.AddrCluster.AsNetIP().Equal(filter.DestIp), "IP matches")
+	require.Equal(t, beAddr.Port, filter.DestPort, "Port matches")
+
+	if !hostOnly {
+		filter = <-mock.requests
+		require.True(t, beAddr.AddrCluster.AsNetIP().Equal(filter.DestIp), "IP matches")
+		require.Equal(t, beAddr.Port, filter.DestPort, "Port matches")
+		require.ElementsMatch(t, visitedNamespaces, []*netns.NetNS{hostNS, fooNS})
+	} else {
+		require.ElementsMatch(t, visitedNamespaces, []*netns.NetNS{hostNS})
+	}
+
+}
+
+type mockDestroyer struct {
+	requests chan sockets.SocketFilter
+}
+
+// Destroy implements sockets.SocketDestroyer.
+func (m *mockDestroyer) Destroy(filter sockets.SocketFilter) error {
+	m.requests <- filter
+	return nil
+}
+
+var _ sockets.SocketDestroyer = &mockDestroyer{}
+
+func initializeNetns(t *testing.T, ns *netns.NetNS, addr string) net.Conn {
+	var conn net.Conn
+	assert.NoError(t, ns.Do(func() error {
+		ls, err := netlink.LinkList()
+		assert.NoError(t, err)
+		for _, l := range ls {
+			// Netns should be default created with loopback dev
+			// we assign a localhost address to it to allow us to
+			// bind sockets.
+			if l.Attrs().Name == "lo" {
+				netlink.AddrAdd(l, &netlink.Addr{
+					IPNet: &net.IPNet{
+						IP:   net.ParseIP("127.0.0.1"),
+						Mask: net.IPv4Mask(0xff, 0xff, 0xff, 0xff),
+					},
+				})
+			}
+			_, err := netlink.AddrList(l, unix.AF_INET)
+			assert.NoError(t, err)
+		}
+		conn, err = net.Dial("udp", addr)
+		assert.NoError(t, err)
+		conn.Write([]byte("ping"))
+		return err
+	}))
+	return conn
+}
+
+func TestSocketTermination_Datapath(t *testing.T) {
+	testutils.PrivilegedTest(t)
+
+	ns1, err := netns.New()
+	require.NoError(t, err)
+	// ns2 has a connection that should not be matched due
+	// to a different port.
+	ns2, err := netns.New()
+	require.NoError(t, err)
+	// ns3 will match revnat, but with a different cookie value
+	// so we expect to avoid a socket close (i.e. this is
+	// the case where we have matching tuple values, but
+	// in an unexpected socket cookie value).
+	ns3, err := netns.New()
+	require.NoError(t, err)
+
+	var conn1, conn2, conn3 net.Conn
+	conn1 = initializeNetns(t, ns1, "127.0.0.1:30000")
+	defer conn1.Close()
+
+	conn2 = initializeNetns(t, ns2, "127.0.0.1:30002")
+	defer conn2.Close()
+
+	conn3 = initializeNetns(t, ns3, "127.0.0.1:30001")
+	defer conn3.Close()
+
+	getCookie := func(ns *netns.NetNS, port uint16) uint32 {
+		if ns == nil {
+			var err error
+			ns, err = netns.Current()
+			require.NoError(t, err)
+		}
+		out := uint32(0)
+		ns.Do(func() error {
+			sock, err := netlink.SocketDiagUDP(unix.AF_INET)
+			assert.NoError(t, err)
+			for _, s := range sock {
+				if s.ID.DestinationPort == port {
+					out = s.ID.Cookie[0]
+					break
+				}
+			}
+			return nil
+		})
+		return out
+	}
+
+	cookie := getCookie(ns1, 30000)
+
+	extConfig := loadbalancer.ExternalConfig{
+		BPFSocketLBHostnsOnly:                  false,
+		EnableSocketLB:                         true,
+		EnableSocketLBPodConnectionTermination: true,
+		EnableIPv4:                             true,
+		EnableIPv6:                             true,
+	}
+
+	// Set up the LBMaps implementation. Since we're running privileged this will
+	// use an unpinned real BPF map.
+	var lbmap lbmaps.LBMaps
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+		cell.Config(loadbalancer.DefaultUserConfig),
+		cell.Config(loadbalancer.DeprecatedConfig{}),
+		cell.Provide(
+			loadbalancer.NewConfig,
+			func() loadbalancer.ExternalConfig { return extConfig },
+			func() *option.DaemonConfig { return &option.DaemonConfig{} },
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+		),
+		cell.Invoke(func(m lbmaps.LBMaps) {
+			lbmap = m
+		}),
+	)
+
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Set up the parameters that [terminateUDPConnectionsToBackend] needs.
+	params := socketTerminationParams{
+		JobGroup:        nil,
+		DB:              nil,
+		Backends:        nil,
+		Log:             log,
+		Config:          loadbalancer.DefaultConfig,
+		ExtConfig:       extConfig,
+		LBMaps:          lbmap,
+		SocketDestroyer: &socketDestroyer{log},
+		NetNSOps: netnsOps{
+			current: netns.Current,
+			do:      (*netns.NetNS).Do,
+			all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+				errs := make(chan error)
+				close(errs)
+				return maps.All(map[string]*netns.NetNS{
+					"cni-0000": ns1,
+					"cni-0001": ns2,
+					"cni-0002": ns3,
+				}), errs
+			},
+		},
+	}
+
+	lbmap.UpdateSockRevNat(uint64(cookie), net.IP{127, 0, 0, 1}, 30000, 0)
+
+	ip, err := netip.ParseAddr("127.0.0.1")
+	require.NoError(t, err)
+	l4a := loadbalancer.NewL3n4Addr(loadbalancer.UDP, cmtypes.AddrClusterFrom(ip, 0), 30000, 0)
+
+	assertForceClose := func(closed bool, c net.Conn) {
+		if closed {
+			c.SetDeadline(time.Now().Add(time.Millisecond * 250))
+			_, err = c.Read([]byte{0})
+			assert.ErrorIs(t, err, unix.ECONNABORTED, "first sock connection should have been aborted")
+		} else {
+			c.SetDeadline(time.Now().Add(time.Millisecond * 250))
+			_, err = c.Read([]byte{0})
+			//nolint:errorlint
+			assert.True(t, err.(net.Error).Timeout(),
+				"other connection should not be prematurely closed, thus read cmd on the sock should simply be allowed to timeout")
+		}
+	}
+
+	// 1. First, we have conn1 in ns1 which has:
+	// 	* Is tracked in the l3nl4addr map.
+	// 	* Real socket cookie.
+	// 	* BPFSocketLBHostnsOnly is disabled
+	// Therefore we expect a socket close.
+	terminateUDPConnectionsToBackend(params, *l4a)
+
+	assertForceClose(true, conn1)
+	assertForceClose(false, conn2)
+
+	l4a = loadbalancer.NewL3n4Addr(loadbalancer.UDP, cmtypes.AddrClusterFrom(ip, 0), 30001, 0)
+	terminateUDPConnectionsToBackend(params, *l4a)
+	assertForceClose(false, conn3)
+
+	// 2. Will otherwise close, but we have lb host ns only enabled so we expect
+	// 	connection to *not* close.
+	assert.NoError(t, ns3.Do(func() error {
+		conn3, err = net.Dial("udp", "127.0.0.1:30001")
+		assert.NoError(t, err)
+		return nil
+	}))
+	cookie3 := getCookie(ns3, 30001)
+	lbmap.UpdateSockRevNat(uint64(cookie3), net.IP{127, 0, 0, 1}, 30001, 0)
+	l4a = loadbalancer.NewL3n4Addr(loadbalancer.UDP, cmtypes.AddrClusterFrom(ip, 0), 30001, 0)
+	params.ExtConfig.BPFSocketLBHostnsOnly = true
+	terminateUDPConnectionsToBackend(params, *l4a)
+	assertForceClose(false, conn3)
+
+	// 3. Now we try a similar test, but with a connection in host ns
+	// 	so this one should close.
+	conn3, err = net.Dial("udp", "127.0.0.1:30004")
+	assert.NoError(t, err)
+	lbmap.UpdateSockRevNat(uint64(getCookie(nil, 30004)), net.IP{127, 0, 0, 1}, 30004, 0)
+	l4a = loadbalancer.NewL3n4Addr(loadbalancer.UDP, cmtypes.AddrClusterFrom(ip, 0), 30004, 0)
+	terminateUDPConnectionsToBackend(params, *l4a)
+	assertForceClose(true, conn3)
+
+	// 4. Now we try one in ns3 again, but we turn off lb host ns only so we expect a connection
+	// 	to be closed.
+	assert.NoError(t, ns3.Do(func() error {
+		conn3, err = net.Dial("udp", "127.0.0.1:30003")
+		assert.NoError(t, err)
+		return nil
+	}))
+	cookie3 = getCookie(ns3, 30003)
+	lbmap.UpdateSockRevNat(uint64(cookie3), net.IP{127, 0, 0, 1}, 30003, 0)
+	l4a = loadbalancer.NewL3n4Addr(loadbalancer.UDP, cmtypes.AddrClusterFrom(ip, 0), 30003, 0)
+
+	params.ExtConfig.BPFSocketLBHostnsOnly = false
+	terminateUDPConnectionsToBackend(params, *l4a)
+	assertForceClose(true, conn3)
+}
+
+// BenchmarkChangeIteration_TCP validates the CPU cost of processing unrelevant backend changes
+// to judge whether the separation of this logic from the reconciler is worthwhile considering
+// that we have to look at each changed Backend object.
+func BenchmarkChangeIteration_TCP(b *testing.B) {
+	benchmarkChangeIteration(b, loadbalancer.TCP)
+}
+
+// BenchmarkChangeIteration_UDP is the same as BenchmarkChangeIteration_TCP but
+// each backend is a UDP backend, thus hitting the more costly "IsAlive()" check.
+func BenchmarkChangeIteration_UDP(b *testing.B) {
+	benchmarkChangeIteration(b, loadbalancer.UDP)
+}
+
+func benchmarkChangeIteration(b *testing.B, proto loadbalancer.L4Type) {
+	// Skip the test by default as it uses fair bit of memory.
+	b.Skip()
+
+	db := statedb.New()
+	backends, err := loadbalancer.NewBackendsTable(db)
+	require.NoError(b, err)
+
+	const numBackends = 1000000
+
+	// Add some backends to iterate over
+	wtxn := db.WriteTxn(backends)
+	for i := range numBackends {
+		addr := [4]byte{1, byte(i / (256 * 256)), byte(i / 256), byte(i % 256)}
+		be := loadbalancer.Backend{}
+		be.Address.AddrCluster = cmtypes.AddrClusterFrom(netip.AddrFrom4(addr), 0)
+		be.Address.L4Addr.Protocol = proto
+		be.Instances = be.Instances.Set(
+			loadbalancer.BackendInstanceKey{
+				ServiceName:    loadbalancer.ServiceName{Namespace: "foo", Name: "bar"},
+				SourcePriority: 0,
+			}, loadbalancer.BackendParams{
+				Address:   be.Address,
+				Source:    source.Kubernetes,
+				State:     loadbalancer.BackendStateActive,
+				Unhealthy: false,
+			})
+		backends.Insert(wtxn, &be)
+	}
+	wtxn.Commit()
+
+	b.ResetTimer()
+
+	for b.Loop() {
+		// Create the change iterator. This adds a bit of overhead, but since
+		// we're doing massive amount of backends it won't change the result.
+		wtxn := db.WriteTxn(backends)
+		changeIter, err := backends.Changes(wtxn)
+		require.NoError(b, err)
+		wtxn.Commit()
+
+		changes, _ := changeIter.Next(wtxn)
+		total, dead := 0, 0
+		for change := range changes {
+			backend := change.Object
+			total++
+			if change.Object.Address.L4Addr.Protocol != loadbalancer.UDP {
+				continue
+			}
+			if change.Deleted || !backend.IsAlive() {
+				dead++
+			}
+		}
+
+		require.Equal(b, numBackends, total)
+		require.Equal(b, 0, dead)
+	}
+
+	b.ReportMetric(float64(b.N*numBackends)/b.Elapsed().Seconds(), "backends/sec")
+	b.ReportMetric(float64(b.Elapsed())/float64(b.N*numBackends), "ns/backend")
+}
diff --git a/pkg/netns/netns_other.go b/pkg/netns/netns_other.go
index 2c4a687dba395..e3e1cc288341a 100644
--- a/pkg/netns/netns_other.go
+++ b/pkg/netns/netns_other.go
@@ -7,6 +7,7 @@ package netns
 
 import (
 	"fmt"
+	"iter"
 )
 
 type NetNS struct{}
@@ -30,3 +31,14 @@ func (h *NetNS) Close() error {
 func (h *NetNS) Do(func() error) error {
 	return fmt.Errorf("not implemented")
 }
+
+func Current() (*NetNS, error) {
+	return nil, fmt.Errorf("not implemented")
+}
+
+func All() (iter.Seq2[string, *NetNS], <-chan error) {
+	errs := make(chan error, 1)
+	errs <- fmt.Errorf("not implemented")
+	close(errs)
+	return nil, errs
+}


Reviews:
user: login: tommyp1ckles
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T04:22:32Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T08:07:19Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T08:07:51Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T08:09:12Z
user: login: jrife
body: I cherry-picked my changes from https://github.com/cilium/cilium/pull/38693 on top of this branch and tested socket termination manually. Everything seemed to work; I saw the UDP socket get destroyed repeatedly.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T22:29:11Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T07:51:09Z
user: login: jrife
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T15:03:46Z
user: login: aditighag
body: Sorry for the delay. Posted some comments from the first pass.
(1) Can you split the 2nd commit into preparatory changes (e.g., changes related to sock rev nat map creation/utility functions, etc) and changes porting the socket termination logic to the new LB control plane?
(2) How do we ensure the socket termination loop runs only on the relevant backend DB updates? 
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-04-30T17:42:36Z
user: login: aditighag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T17:52:51Z
user: login: tommyp1ckles
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T04:39:06Z
user: login: tommyp1ckles
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T04:47:15Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:32:03Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:33:51Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:42:41Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:42:52Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:43:12Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:03:35Z
user: login: tommyp1ckles
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T17:53:09Z
user: login: joamaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T09:19:06Z
user: login: dylandreimerink
body: Look good to me, any concerns were already brought up by other reviewers
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T11:43:45Z

Review comments:
diff_hunk: @@ -102,10 +108,15 @@ type LBMapsConfig struct {
 	MaglevMapMaxEntries                                             int
 }
 
+const defaultMaxSockRevNatEntries = 256 * 1024
path: pkg/loadbalancer/config.go
user: login: tommyp1ckles
body: Why this number in particular?
created_at: 2025-04-23T04:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
path: pkg/loadbalancer/reconciler/termination.go
user: login: tommyp1ckles
body: why do we commit if it fails?
created_at: 2025-04-23T04:12:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
path: pkg/loadbalancer/reconciler/termination.go
user: login: tommyp1ckles
body: Would it be better to just probe for this kernel functionality once and then avoid retrying this and then avoid having these error logs periodically appear?
created_at: 2025-04-23T04:19:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,10 +108,15 @@ type LBMapsConfig struct {
 	MaglevMapMaxEntries                                             int
 }
 
+const defaultMaxSockRevNatEntries = 256 * 1024
path: pkg/loadbalancer/config.go
user: login: joamaki
body: Unclear to me why this was chosen originally (https://github.com/cilium/cilium/blob/main/pkg/maps/lbmap/ipv4.go#L29) 
created_at: 2025-04-23T08:07:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
path: pkg/loadbalancer/reconciler/termination.go
user: login: joamaki
body: simpler code, nothing inserted if it fails, shouldn't really never fail.
created_at: 2025-04-23T08:07:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
path: pkg/loadbalancer/reconciler/termination.go
user: login: joamaki
body: This was how the original implemented it and didn't feel like diving into how to do this differently when porting this. This is not retrying as it's exiting the one-off job here.
created_at: 2025-04-23T08:09:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
path: pkg/loadbalancer/reconciler/termination.go
user: login: jrife
body: With `BPFSocketDestroyer`, I'm probing anyway at startup to see what's supported. IMO it's better to just leave the probing logic there and report one time. e.g. from my code:

```golang
	cell.Provide(func(l *slog.Logger) (sockets.SocketDestroyer, error) {
		bpfSD := &sockets.BPFSocketDestroyer{
			Logger: l,
		}

		if supported, err := bpfSD.IsSupported(); err != nil {
			return nil, fmt.Errorf("probing BPF socket destroyer support: %w", err)
		} else if supported {
			l.Info("Using BPF socket destroyer")
			return bpfSD, nil
		}

		l.Info("bpf_sock_destroy is not supported on the current kernel. Falling back to netlink-based socket destroyer")
		return &sockets.NetlinkSocketDestroyer{
			Logger: l,
		}, nil
	}),
```

Right now it just assumes the `NetlinkSocketDestroyer` will work, but it would be possible to add an `IsSupported()` function for that as well here. Worst case, this returns `nil` or an implementation of `SocketDestroyer` that does nothing and socket termination does nothing.
created_at: 2025-04-23T21:56:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
path: pkg/loadbalancer/reconciler/termination.go
user: login: joamaki
body: Yeah that makes sense to me. Would it be OK if you'd do that change in your PR once this is in?
created_at: 2025-04-24T07:51:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
+				opSupported := terminateUDPConnectionsToBackend(p, backend.Address)
path: pkg/loadbalancer/reconciler/termination.go
user: login: jrife
body: Yeah, sure. I will likely drop the `struct socketDestroyer` as when I rebase on this, since SocketDestroyer is `Provide`ed elsewhere. We can discuss more after merging this.
created_at: 2025-04-24T15:03:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
path: pkg/loadbalancer/reconciler/termination.go
user: login: aditighag
body: Is it possible to get an update for a backend even when there was neither a change in the state nor it was deleted?
created_at: 2025-04-30T17:32:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
+		t.SkipNow()
+	}
+
+	var (
+		db       *statedb.DB
+		backends statedb.RWTable[*loadbalancer.Backend]
+	)
+	mock := &mockDestroyer{
+		requests: make(chan sockets.SocketFilter, 10),
+	}
+	var beAddr loadbalancer.L3n4Addr
+	require.NoError(t, beAddr.ParseFromString("1.0.0.1:80/UDP"))
+
+	syncChan := make(testSyncChan)
+
+	visitedNamespaces := []*netns.NetNS{}
+	hostNS := &netns.NetNS{}
+	fooNS := &netns.NetNS{}
+
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+
+		cell.Provide(
+			loadbalancer.NewBackendsTable,
+			statedb.RWTable[*loadbalancer.Backend].ToTable,
+			func() sockets.SocketDestroyer { return mock },
+			func() loadbalancer.Config {
+				return loadbalancer.Config{
+					EnableExperimentalLB: true,
+					RetryBackoffMin:      0,
+					RetryBackoffMax:      0,
+				}
+			},
+			func() loadbalancer.ExternalConfig {
+				return loadbalancer.ExternalConfig{
+					BPFSocketLBHostnsOnly:                  false,
+					EnableSocketLB:                         true,
+					EnableSocketLBPodConnectionTermination: true,
+					LBMapsConfig:                           loadbalancer.LBMapsConfig{},
+					EnableIPv4:                             true,
+					EnableIPv6:                             true,
+				}
+
+			},
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+			func() testSyncChan { return syncChan },
+			func() netnsOps {
+				return netnsOps{
+					current: func() (*netns.NetNS, error) {
+						return hostNS, nil
+					},
+					do: func(ns *netns.NetNS, f func() error) error {
+						visitedNamespaces = append(visitedNamespaces, ns)
+						return f()
+					},
+					all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+						errs := make(chan error)
+						close(errs)
+						return maps.All(map[string]*netns.NetNS{
+							"foo": fooNS,
+						}), errs
+					},
+				}
+			},
+		),
+		cell.Module("test", "test",
+			cell.Invoke(registerSocketTermination),
+		),
+		cell.Invoke(func(db_ *statedb.DB, backends_ statedb.RWTable[*loadbalancer.Backend]) {
+			db = db_
+			backends = backends_
+
+		}),
+	)
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Add a backends and wait for the job to pick it up
+	wtxn := db.WriteTxn(backends)
+	backends.Insert(wtxn, &loadbalancer.Backend{Address: beAddr})
+	wtxn.Commit()
+
+	// Wait until the first change has been seen
+	<-syncChan
+
+	wtxn = db.WriteTxn(backends)
+	backends.DeleteAll(wtxn)
+	wtxn.Commit()
+
+	// We should see two deletions: one for host ns and one for the mocked
+	// "foo" one.
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: aditighag
body: Can we add two separate test cases -- (1) when socket-LB is enabled only in the host netns and verify that stale connections are terminated only in the host netns? (2) stale connections are terminated in both host netns and pod netns. 
created_at: 2025-04-30T17:37:20Z
author_association: MEMBER
diff_hunk: @@ -762,6 +804,10 @@ func (f *FaultyLBMaps) DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOute
 	return f.impl.DumpMaglev(cb)
 }
 
+func (f *FaultyLBMaps) ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool {
path: pkg/loadbalancer/maps/lbmaps.go
user: login: aditighag
body: Umm... What's a faulty vs fake LB map? How is it relevant to this PR? Please consider splitting this change into a separate commit. 
created_at: 2025-04-30T17:42:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
path: pkg/loadbalancer/reconciler/termination.go
user: login: aditighag
body: i like this approach for testing.
created_at: 2025-04-30T17:52:51Z
author_association: MEMBER
diff_hunk: @@ -124,6 +124,21 @@ func (be *Backend) GetInstanceFromSource(name ServiceName, src source.Source) *B
 	return nil
 }
 
+// IsAlive returns true if any of the instances are marked active or terminanting and healthy.
path: pkg/loadbalancer/backend.go
user: login: tommyp1ckles
body: typo: terminanting -> terminating
created_at: 2025-05-01T16:16:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
+		t.SkipNow()
+	}
+
+	var (
+		db       *statedb.DB
+		backends statedb.RWTable[*loadbalancer.Backend]
+	)
+	mock := &mockDestroyer{
+		requests: make(chan sockets.SocketFilter, 10),
+	}
+	var beAddr loadbalancer.L3n4Addr
+	require.NoError(t, beAddr.ParseFromString("1.0.0.1:80/UDP"))
+
+	syncChan := make(testSyncChan)
+
+	visitedNamespaces := []*netns.NetNS{}
+	hostNS := &netns.NetNS{}
+	fooNS := &netns.NetNS{}
+
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+
+		cell.Provide(
+			loadbalancer.NewBackendsTable,
+			statedb.RWTable[*loadbalancer.Backend].ToTable,
+			func() sockets.SocketDestroyer { return mock },
+			func() loadbalancer.Config {
+				return loadbalancer.Config{
+					EnableExperimentalLB: true,
+					RetryBackoffMin:      0,
+					RetryBackoffMax:      0,
+				}
+			},
+			func() loadbalancer.ExternalConfig {
+				return loadbalancer.ExternalConfig{
+					BPFSocketLBHostnsOnly:                  false,
+					EnableSocketLB:                         true,
+					EnableSocketLBPodConnectionTermination: true,
+					LBMapsConfig:                           loadbalancer.LBMapsConfig{},
+					EnableIPv4:                             true,
+					EnableIPv6:                             true,
+				}
+
+			},
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+			func() testSyncChan { return syncChan },
+			func() netnsOps {
+				return netnsOps{
+					current: func() (*netns.NetNS, error) {
+						return hostNS, nil
+					},
+					do: func(ns *netns.NetNS, f func() error) error {
+						visitedNamespaces = append(visitedNamespaces, ns)
+						return f()
+					},
+					all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+						errs := make(chan error)
+						close(errs)
+						return maps.All(map[string]*netns.NetNS{
+							"foo": fooNS,
+						}), errs
+					},
+				}
+			},
+		),
+		cell.Module("test", "test",
+			cell.Invoke(registerSocketTermination),
+		),
+		cell.Invoke(func(db_ *statedb.DB, backends_ statedb.RWTable[*loadbalancer.Backend]) {
+			db = db_
+			backends = backends_
+
+		}),
+	)
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Add a backends and wait for the job to pick it up
+	wtxn := db.WriteTxn(backends)
+	backends.Insert(wtxn, &loadbalancer.Backend{Address: beAddr})
+	wtxn.Commit()
+
+	// Wait until the first change has been seen
+	<-syncChan
+
+	wtxn = db.WriteTxn(backends)
+	backends.DeleteAll(wtxn)
+	wtxn.Commit()
+
+	// We should see two deletions: one for host ns and one for the mocked
+	// "foo" one.
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: tommyp1ckles
body: The [legacy](https://github.com/cilium/cilium/blob/main/pkg/loadbalancer/legacy/service/service_test.go#L2759-L2904) test also has coverage for checking that socket-cookie validation works. 
created_at: 2025-05-05T04:35:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: tommyp1ckles
body: Q: I don't see many tests doing this, should this just be something we tool as part of `testutils.Privileged()`?
created_at: 2025-05-05T04:38:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
path: pkg/loadbalancer/reconciler/termination.go
user: login: tommyp1ckles
body: Just as a point of discussion, if we're doing a batched termination approach - we could refactor the `Destroy` function to accept the whole batch of `SocketFilter`'s and then check for them all at once, avoiding unnecessary netlink iterations/message passing.   
created_at: 2025-05-05T04:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
path: pkg/loadbalancer/reconciler/termination.go
user: login: tommyp1ckles
body: Probably makes sense as a follow up though.
created_at: 2025-05-05T04:47:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -762,6 +804,10 @@ func (f *FaultyLBMaps) DumpMaglev(cb func(lbmap.MaglevOuterKey, lbmap.MaglevOute
 	return f.impl.DumpMaglev(cb)
 }
 
+func (f *FaultyLBMaps) ExistsSockRevNat(cookie uint64, addr net.IP, port uint16) bool {
path: pkg/loadbalancer/maps/lbmaps.go
user: login: joamaki
body: FaultyLBMaps is wrapping a `LBMaps` (either a fake or the real one) to provide fault injection for the tests. This way almost every test case we have exercises the retry machinery of the `pkg/loadbalancer/reconciler` as any BPF map operation can fail with some configurable probability. 

I cannot move it to a separate commit. Any addition to `LBMaps` must be implemented by `FakeLBMaps`, `BPFLBMaps` and by `FaultyLBMaps`. The code would fail to compile otherwise and if I move it to a separate commit the "build commits" CI job would fail.
created_at: 2025-05-05T11:32:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
+	limiter := rate.NewLimiter(50*time.Millisecond, 1)
+	defer limiter.Stop()
+
+	for {
+		changes, watch := changeIter.Next(p.DB.ReadTxn())
+		for change := range changes {
+			backend := change.Object
+
+			if p.TestSyncChan != nil {
+				close(p.TestSyncChan)
+				p.TestSyncChan = nil
+			}
+
+			// Terminate the sockets connected to backends that have been either
+			// deleted or which are no longer considered viable.
+			if change.Deleted || !backend.IsAlive() {
path: pkg/loadbalancer/reconciler/termination.go
user: login: joamaki
body: No it is not. The object in the change must have been either `Insert`ed or `Delete`d. Of course the contents of the object could be the same (even though it must be pointing to a different copy in the heap since it's a pointer (`Insert()` will panic otherwise)).
created_at: 2025-05-05T11:33:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: joamaki
body: What do you mean? "Tool" as in?
created_at: 2025-05-05T11:42:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,252 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"iter"
+	"log/slog"
+	"net"
+	"syscall"
+
+	"github.com/vishvananda/netlink"
+	"golang.org/x/sys/unix"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/job"
+	"github.com/cilium/statedb"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	lb "github.com/cilium/cilium/pkg/loadbalancer"
+	"github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/logging/logfields"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/rate"
+	"github.com/cilium/cilium/pkg/time"
+)
+
+// SocketTerminationCell runs a background job that monitors the backends table for
+// unhealthy and deleted backends and terminates UDP sockets connected to these backends
+// to signal to the application that the destination has become unreachable.
+var SocketTerminationCell = cell.Module(
+	"socket-termination",
+	"Terminates sockets connected to deleted backends",
+
+	// Provide the default implementations for accessing network namespaces and
+	// destroying sockets. This allows the tests to use "registerSocketTermination"
+	// directly and provide custom implementations of these.
+	cell.ProvidePrivate(
+		func() netnsOps {
+			return netnsOps{
+				current: netns.Current,
+				do:      (*netns.NetNS).Do,
+				all:     netns.All,
+			}
+		},
+		func(log *slog.Logger) sockets.SocketDestroyer {
+			return socketDestroyer{log}
+		},
+	),
+
+	cell.Invoke(registerSocketTermination),
+)
+
+type socketTerminationParams struct {
+	cell.In
+
+	JobGroup  job.Group
+	DB        *statedb.DB
+	Backends  statedb.Table[*lb.Backend]
+	Log       *slog.Logger
+	Config    lb.Config
+	ExtConfig lb.ExternalConfig
+	LBMaps    maps.LBMaps
+
+	SocketDestroyer sockets.SocketDestroyer
+	NetNSOps        netnsOps
+
+	// TestSyncChan is used by the tests to synchronize with the job so it
+	// knows when to delete the backend.
+	TestSyncChan testSyncChan   `optional:"true"`
+	TestConfig   *lb.TestConfig `optional:"true"`
+}
+
+type testSyncChan chan struct{}
+
+// netnsOps captures the operations performed with network namespaces.
+// This allows the tests to inject their own implementation.
+type netnsOps struct {
+	current func() (*netns.NetNS, error)
+	do      func(*netns.NetNS, func() error) error
+	all     func() (iter.Seq2[string, *netns.NetNS], <-chan error)
+}
+
+type socketDestroyer struct {
+	log *slog.Logger
+}
+
+func (sd socketDestroyer) Destroy(filter sockets.SocketFilter) error {
+	return sockets.Destroy(sd.log, filter)
+}
+
+func registerSocketTermination(p socketTerminationParams) error {
+	if !p.Config.EnableExperimentalLB {
+		return nil
+	}
+
+	if p.SocketDestroyer == nil {
+		// To make the load-balancer cell easier to use in tests we don't require that
+		// SocketDestroyer is always provided.
+		if p.TestConfig == nil {
+			return fmt.Errorf("SocketDestroyer not provided and not running in tests")
+		}
+		return nil
+	}
+
+	if !(p.ExtConfig.EnableSocketLB || p.ExtConfig.BPFSocketLBHostnsOnly) {
+		return nil
+	}
+
+	p.JobGroup.Add(
+		job.OneShot(
+			"socket-termination",
+			func(ctx context.Context, h cell.Health) error {
+				return socketTerminationLoop(p, ctx, h)
+			},
+		))
+
+	return nil
+}
+
+func socketTerminationLoop(p socketTerminationParams, ctx context.Context, health cell.Health) error {
+	wtxn := p.DB.WriteTxn(p.Backends)
+	changeIter, err := p.Backends.Changes(wtxn)
+	wtxn.Commit()
+	if err != nil {
+		return err
+	}
+
+	// Process the changes in batches every 50 milliseconds.
path: pkg/loadbalancer/reconciler/termination.go
user: login: joamaki
body: Yeah we should, but not in this PR
created_at: 2025-05-05T11:42:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
+		t.SkipNow()
+	}
+
+	var (
+		db       *statedb.DB
+		backends statedb.RWTable[*loadbalancer.Backend]
+	)
+	mock := &mockDestroyer{
+		requests: make(chan sockets.SocketFilter, 10),
+	}
+	var beAddr loadbalancer.L3n4Addr
+	require.NoError(t, beAddr.ParseFromString("1.0.0.1:80/UDP"))
+
+	syncChan := make(testSyncChan)
+
+	visitedNamespaces := []*netns.NetNS{}
+	hostNS := &netns.NetNS{}
+	fooNS := &netns.NetNS{}
+
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+
+		cell.Provide(
+			loadbalancer.NewBackendsTable,
+			statedb.RWTable[*loadbalancer.Backend].ToTable,
+			func() sockets.SocketDestroyer { return mock },
+			func() loadbalancer.Config {
+				return loadbalancer.Config{
+					EnableExperimentalLB: true,
+					RetryBackoffMin:      0,
+					RetryBackoffMax:      0,
+				}
+			},
+			func() loadbalancer.ExternalConfig {
+				return loadbalancer.ExternalConfig{
+					BPFSocketLBHostnsOnly:                  false,
+					EnableSocketLB:                         true,
+					EnableSocketLBPodConnectionTermination: true,
+					LBMapsConfig:                           loadbalancer.LBMapsConfig{},
+					EnableIPv4:                             true,
+					EnableIPv6:                             true,
+				}
+
+			},
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+			func() testSyncChan { return syncChan },
+			func() netnsOps {
+				return netnsOps{
+					current: func() (*netns.NetNS, error) {
+						return hostNS, nil
+					},
+					do: func(ns *netns.NetNS, f func() error) error {
+						visitedNamespaces = append(visitedNamespaces, ns)
+						return f()
+					},
+					all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+						errs := make(chan error)
+						close(errs)
+						return maps.All(map[string]*netns.NetNS{
+							"foo": fooNS,
+						}), errs
+					},
+				}
+			},
+		),
+		cell.Module("test", "test",
+			cell.Invoke(registerSocketTermination),
+		),
+		cell.Invoke(func(db_ *statedb.DB, backends_ statedb.RWTable[*loadbalancer.Backend]) {
+			db = db_
+			backends = backends_
+
+		}),
+	)
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Add a backends and wait for the job to pick it up
+	wtxn := db.WriteTxn(backends)
+	backends.Insert(wtxn, &loadbalancer.Backend{Address: beAddr})
+	wtxn.Commit()
+
+	// Wait until the first change has been seen
+	<-syncChan
+
+	wtxn = db.WriteTxn(backends)
+	backends.DeleteAll(wtxn)
+	wtxn.Commit()
+
+	// We should see two deletions: one for host ns and one for the mocked
+	// "foo" one.
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: joamaki
body: I'll add that in as well.
created_at: 2025-05-05T11:43:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
+		t.SkipNow()
+	}
+
+	var (
+		db       *statedb.DB
+		backends statedb.RWTable[*loadbalancer.Backend]
+	)
+	mock := &mockDestroyer{
+		requests: make(chan sockets.SocketFilter, 10),
+	}
+	var beAddr loadbalancer.L3n4Addr
+	require.NoError(t, beAddr.ParseFromString("1.0.0.1:80/UDP"))
+
+	syncChan := make(testSyncChan)
+
+	visitedNamespaces := []*netns.NetNS{}
+	hostNS := &netns.NetNS{}
+	fooNS := &netns.NetNS{}
+
+	h := hive.New(
+		maglev.Cell,
+		lbmaps.Cell,
+
+		cell.Provide(
+			loadbalancer.NewBackendsTable,
+			statedb.RWTable[*loadbalancer.Backend].ToTable,
+			func() sockets.SocketDestroyer { return mock },
+			func() loadbalancer.Config {
+				return loadbalancer.Config{
+					EnableExperimentalLB: true,
+					RetryBackoffMin:      0,
+					RetryBackoffMax:      0,
+				}
+			},
+			func() loadbalancer.ExternalConfig {
+				return loadbalancer.ExternalConfig{
+					BPFSocketLBHostnsOnly:                  false,
+					EnableSocketLB:                         true,
+					EnableSocketLBPodConnectionTermination: true,
+					LBMapsConfig:                           loadbalancer.LBMapsConfig{},
+					EnableIPv4:                             true,
+					EnableIPv6:                             true,
+				}
+
+			},
+			func() *loadbalancer.TestConfig { return &loadbalancer.TestConfig{} },
+			func() testSyncChan { return syncChan },
+			func() netnsOps {
+				return netnsOps{
+					current: func() (*netns.NetNS, error) {
+						return hostNS, nil
+					},
+					do: func(ns *netns.NetNS, f func() error) error {
+						visitedNamespaces = append(visitedNamespaces, ns)
+						return f()
+					},
+					all: func() (iter.Seq2[string, *netns.NetNS], <-chan error) {
+						errs := make(chan error)
+						close(errs)
+						return maps.All(map[string]*netns.NetNS{
+							"foo": fooNS,
+						}), errs
+					},
+				}
+			},
+		),
+		cell.Module("test", "test",
+			cell.Invoke(registerSocketTermination),
+		),
+		cell.Invoke(func(db_ *statedb.DB, backends_ statedb.RWTable[*loadbalancer.Backend]) {
+			db = db_
+			backends = backends_
+
+		}),
+	)
+	log := hivetest.Logger(t, hivetest.LogLevel(slog.LevelDebug))
+	require.NoError(t, h.Start(log, t.Context()), "Start")
+	t.Cleanup(func() {
+		require.NoError(t, h.Stop(log, context.Background()), "Stop")
+		goleak.VerifyNone(t)
+	})
+
+	// Add a backends and wait for the job to pick it up
+	wtxn := db.WriteTxn(backends)
+	backends.Insert(wtxn, &loadbalancer.Backend{Address: beAddr})
+	wtxn.Commit()
+
+	// Wait until the first change has been seen
+	<-syncChan
+
+	wtxn = db.WriteTxn(backends)
+	backends.DeleteAll(wtxn)
+	wtxn.Commit()
+
+	// We should see two deletions: one for host ns and one for the mocked
+	// "foo" one.
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: joamaki
body: Ported the test from service_test.go and I expanded the other test to cover the "only hostns" case as well.
created_at: 2025-05-05T13:03:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: tommyp1ckles
body: If we want to avoid running non-privileged tests on the privileged runner, to save on CI time then we should make that automatically happen, instead of having to manually set this up for each test.
created_at: 2025-05-05T17:53:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,147 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright Authors of Cilium
+package reconciler
+
+import (
+	"context"
+	"iter"
+	"log/slog"
+	"maps"
+	"testing"
+
+	"github.com/cilium/hive/cell"
+	"github.com/cilium/hive/hivetest"
+	"github.com/cilium/statedb"
+	"github.com/stretchr/testify/require"
+	"go.uber.org/goleak"
+
+	"github.com/cilium/cilium/pkg/datapath/sockets"
+	"github.com/cilium/cilium/pkg/hive"
+	"github.com/cilium/cilium/pkg/loadbalancer"
+	lbmaps "github.com/cilium/cilium/pkg/loadbalancer/maps"
+	"github.com/cilium/cilium/pkg/maglev"
+	"github.com/cilium/cilium/pkg/netns"
+	"github.com/cilium/cilium/pkg/testutils"
+)
+
+func TestSocketTermination(t *testing.T) {
+	if testutils.IsPrivileged() {
path: pkg/loadbalancer/reconciler/termination_test.go
user: login: joamaki
body: Oh you mean I should've just used `testutils.PrivilegedTest()`? Yeah that makes sense. Changed.
created_at: 2025-05-08T09:19:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: maintainer-s-little-helper[bot]
created_at: 2025-04-17T13:33:52Z
body: Commit 7b9b15df77755ddc8640fd176c220b452a5cd715 does not match "(?m)^Signed-off-by:".

Please follow instructions provided in https://docs.cilium.io/en/stable/contributing/development/contributing_guide/#developer-s-certificate-of-origin
author_association: NONE
user: login: maintainer-s-little-helper[bot]
created_at: 2025-04-17T13:34:38Z
body: Commit 7b9b15df77755ddc8640fd176c220b452a5cd715 does not match "(?m)^Signed-off-by:".

Please follow instructions provided in https://docs.cilium.io/en/stable/contributing/development/contributing_guide/#developer-s-certificate-of-origin
author_association: NONE
user: login: joamaki
created_at: 2025-04-22T13:59:09Z
body: /test
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-04-28T12:15:25Z
body: ping @aditighag @tommyp1ckles 
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-04-30T12:24:32Z
body: /test
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-04-30T14:19:45Z
body: ping @aditighag @tommyp1ckles 
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-05T11:34:07Z
body: > Sorry for the delay. Posted some comments from the first pass. (1) Can you split the 2nd commit into preparatory changes (e.g., changes related to sock rev nat map creation/utility functions, etc) and changes porting the socket termination logic to the new LB control plane? (2) How do we ensure the socket termination loop runs only on the relevant backend DB updates?

(1) Sure thing, I'll split up commits a bit.

(2) Can you expand on this? Maybe give some examples of non-relevant updates? Why would we not be interested in every backend that either has been deleted or has become non-viable due to health checking? The only thing I can think of that's "not relevant" is when a backend that has become non-viable (e.g. only has quarantined backends) and then there's a change that still keeps it non-relevant, e.g. one of the quarantined backends is removed. But is there harm in that situation to go and try find sockets to terminate? 
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-05T13:55:53Z
body: /test
author_association: CONTRIBUTOR
user: login: aditighag
created_at: 2025-05-05T22:12:58Z
body: > (2) Can you expand on this? Maybe give some examples of non-relevant updates? Why would we not be interested in every backend that either has been deleted or has become non-viable due to health checking? The only thing I can think of that's "not relevant" is when a backend that has become non-viable (e.g. only has quarantined backends) and then there's a change that still keeps it non-relevant, e.g. one of the quarantined backends is removed. But is there harm in that situation to go and try find sockets to terminate?

There are multiple backend attributes, state being one of them - https://github.com/cilium/cilium/blob/main/pkg/loadbalancer/backend.go#L25-L25. Do we want to run the socket termination loop for backend updates corresponding to changes to attributes such as weight, clusterID, UnhealthyUpdatedAt, etc that don't change its state?

author_association: MEMBER
user: login: joamaki
created_at: 2025-05-06T06:06:46Z
body: > > (2) Can you expand on this? Maybe give some examples of non-relevant updates? Why would we not be interested in every backend that either has been deleted or has become non-viable due to health checking? The only thing I can think of that's "not relevant" is when a backend that has become non-viable (e.g. only has quarantined backends) and then there's a change that still keeps it non-relevant, e.g. one of the quarantined backends is removed. But is there harm in that situation to go and try find sockets to terminate?
> 
> There are multiple backend attributes, state being one of them - https://github.com/cilium/cilium/blob/main/pkg/loadbalancer/backend.go#L25-L25. Do we want to run the socket termination loop for backend updates corresponding to changes to attributes such as weight, clusterID, UnhealthyUpdatedAt, etc that don't change its state?

It waits for changes to take a look at for 50ms and then does a tree traversal to go through them to find relevant changes. This is fast and lockless, so that should be fine. If we want to reduce the overhead of context switching and tree traversal we can bump the waiting up by a bit to make this even cheaper. 

Alternatively we could tie this into the reconciler and when it deletes a backend it could also queue up the socket destruction.

You got me now questioning whether this might be burning enough cycles looking through irrelevant changes that it’s worth saving those. I’ll do a benchmark today to quantify.
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-08T09:25:50Z
body: > > > (2) Can you expand on this? Maybe give some examples of non-relevant updates? Why would we not be interested in every backend that either has been deleted or has become non-viable due to health checking? The only thing I can think of that's "not relevant" is when a backend that has become non-viable (e.g. only has quarantined backends) and then there's a change that still keeps it non-relevant, e.g. one of the quarantined backends is removed. But is there harm in that situation to go and try find sockets to terminate?
> > 
> > 
> > There are multiple backend attributes, state being one of them - https://github.com/cilium/cilium/blob/main/pkg/loadbalancer/backend.go#L25-L25. Do we want to run the socket termination loop for backend updates corresponding to changes to attributes such as weight, clusterID, UnhealthyUpdatedAt, etc that don't change its state?
> 
> It waits for changes to take a look at for 50ms and then does a tree traversal to go through them to find relevant changes. This is fast and lockless, so that should be fine. If we want to reduce the overhead of context switching and tree traversal we can bump the waiting up by a bit to make this even cheaper.
> 
> Alternatively we could tie this into the reconciler and when it deletes a backend it could also queue up the socket destruction.
> 
> You got me now questioning whether this might be burning enough cycles looking through irrelevant changes that it’s worth saving those. I’ll do a benchmark today to quantify.

Ok, I've added a benchmark for the iteration. We're spending 300ns per backend change, which let's say for 100 backend changes would cost 600us, which is about 0.06% of one CPU core (see the comment for more details). So if we'd have a very very busy cluster it's still very little spent on this and it's unlikely that we'd have this sort of constant churn. 

My argument here is that it's better to sacrifice few CPU cycles to have an implementation that is decoupled: the other code has no control coupling to the UDP socket termination and the more we implement in this style the easier the code is to work with => we strive to have each component in the system to have a single concern, referential transparency and less control coupling. This in fact is the core design principle around StateDB: we move away from tight coupling of components and imperative callbacks that not end up with a big ball of mud (e.g. see pkg/service, pkg/endpoint etc.) or reduce latency/throughput in unexpected ways, but also to avoid production incidents like dead locks when one component directly calls into the black box of another (control coupling). That is why I find the trade-off of doing little bit extra work here much better than the alternatives (e.g. BPF reconciler directly coupled to this thing when it deletes a backend).

EDIT: Ah, it's the backend instances lookup that IsAlive() does that takes most of the time. Adding a check for the protocol and skipping non-UDP backends brings the time down to 24ns/backend. So this can now process 41 million backend changes per second if the changes are all non-UDP. That seems fine?
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-09T13:04:57Z
body: /test
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-12T08:20:03Z
body: @aditighag PTAL
author_association: CONTRIBUTOR
user: login: joamaki
created_at: 2025-05-12T08:20:15Z
body: /test
author_association: CONTRIBUTOR
user: login: aditighag
created_at: 2025-06-11T15:55:06Z
body: >There are multiple backend attributes, state being one of them - https://github.com/cilium/cilium/blob/main/pkg/loadbalancer/backend.go#L25-L25. Do we want to run the socket termination loop for backend updates corresponding to changes to attributes such as weight, clusterID, UnhealthyUpdatedAt, etc that don't change its state?

Missed following up -- mainly to highlight the potential overhead of iterating kernel sockets unnecessarily. We can revisit this if users start reporting noticeable overhead. 
The benchmarks you added should also help us keep track of the processing overhead in cilium. 
author_association: MEMBER