batcher: fix closed panic
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
c4600346f9
commit
616c0e895d
@ -1,8 +1,8 @@
|
|||||||
package mapper
|
package mapper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@ -16,6 +16,8 @@ import (
|
|||||||
"tailscale.com/types/ptr"
|
"tailscale.com/types/ptr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errConnectionClosed = errors.New("connection channel already closed")
|
||||||
|
|
||||||
// LockFreeBatcher uses atomic operations and concurrent maps to eliminate mutex contention.
|
// LockFreeBatcher uses atomic operations and concurrent maps to eliminate mutex contention.
|
||||||
type LockFreeBatcher struct {
|
type LockFreeBatcher struct {
|
||||||
tick *time.Ticker
|
tick *time.Ticker
|
||||||
@ -26,9 +28,9 @@ type LockFreeBatcher struct {
|
|||||||
connected *xsync.Map[types.NodeID, *time.Time]
|
connected *xsync.Map[types.NodeID, *time.Time]
|
||||||
|
|
||||||
// Work queue channel
|
// Work queue channel
|
||||||
workCh chan work
|
workCh chan work
|
||||||
ctx context.Context
|
workChOnce sync.Once // Ensures workCh is only closed once
|
||||||
cancel context.CancelFunc
|
done chan struct{}
|
||||||
|
|
||||||
// Batching state
|
// Batching state
|
||||||
pendingChanges *xsync.Map[types.NodeID, []change.ChangeSet]
|
pendingChanges *xsync.Map[types.NodeID, []change.ChangeSet]
|
||||||
@ -144,23 +146,20 @@ func (b *LockFreeBatcher) AddWork(c ...change.ChangeSet) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *LockFreeBatcher) Start() {
|
func (b *LockFreeBatcher) Start() {
|
||||||
b.ctx, b.cancel = context.WithCancel(context.Background())
|
b.done = make(chan struct{})
|
||||||
go b.doWork()
|
go b.doWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *LockFreeBatcher) Close() {
|
func (b *LockFreeBatcher) Close() {
|
||||||
if b.cancel != nil {
|
// Signal shutdown to all goroutines
|
||||||
b.cancel()
|
if b.done != nil {
|
||||||
b.cancel = nil
|
close(b.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only close workCh once
|
// Only close workCh once using sync.Once to prevent races
|
||||||
select {
|
b.workChOnce.Do(func() {
|
||||||
case <-b.workCh:
|
|
||||||
// Channel is already closed
|
|
||||||
default:
|
|
||||||
close(b.workCh)
|
close(b.workCh)
|
||||||
}
|
})
|
||||||
|
|
||||||
// Close the underlying channels supplying the data to the clients.
|
// Close the underlying channels supplying the data to the clients.
|
||||||
b.nodes.Range(func(nodeID types.NodeID, conn *multiChannelNodeConn) bool {
|
b.nodes.Range(func(nodeID types.NodeID, conn *multiChannelNodeConn) bool {
|
||||||
@ -186,8 +185,8 @@ func (b *LockFreeBatcher) doWork() {
|
|||||||
case <-cleanupTicker.C:
|
case <-cleanupTicker.C:
|
||||||
// Clean up nodes that have been offline for too long
|
// Clean up nodes that have been offline for too long
|
||||||
b.cleanupOfflineNodes()
|
b.cleanupOfflineNodes()
|
||||||
case <-b.ctx.Done():
|
case <-b.done:
|
||||||
log.Info().Msg("batcher context done, stopping to feed workers")
|
log.Info().Msg("batcher done channel closed, stopping to feed workers")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -235,7 +234,7 @@ func (b *LockFreeBatcher) worker(workerID int) {
|
|||||||
// Send result
|
// Send result
|
||||||
select {
|
select {
|
||||||
case w.resultCh <- result:
|
case w.resultCh <- result:
|
||||||
case <-b.ctx.Done():
|
case <-b.done:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,8 +257,8 @@ func (b *LockFreeBatcher) worker(workerID int) {
|
|||||||
Msg("failed to apply change")
|
Msg("failed to apply change")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case <-b.ctx.Done():
|
case <-b.done:
|
||||||
log.Debug().Int("workder.id", workerID).Msg("batcher context is done, exiting worker")
|
log.Debug().Int("worker.id", workerID).Msg("batcher shutting down, exiting worker")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -276,7 +275,7 @@ func (b *LockFreeBatcher) queueWork(w work) {
|
|||||||
select {
|
select {
|
||||||
case b.workCh <- w:
|
case b.workCh <- w:
|
||||||
// Successfully queued
|
// Successfully queued
|
||||||
case <-b.ctx.Done():
|
case <-b.done:
|
||||||
// Batcher is shutting down
|
// Batcher is shutting down
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -443,7 +442,7 @@ func (b *LockFreeBatcher) MapResponseFromChange(id types.NodeID, c change.Change
|
|||||||
select {
|
select {
|
||||||
case result := <-resultCh:
|
case result := <-resultCh:
|
||||||
return result.mapResponse, result.err
|
return result.mapResponse, result.err
|
||||||
case <-b.ctx.Done():
|
case <-b.done:
|
||||||
return nil, fmt.Errorf("batcher shutting down while generating map response for node %d", id)
|
return nil, fmt.Errorf("batcher shutting down while generating map response for node %d", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,6 +454,7 @@ type connectionEntry struct {
|
|||||||
version tailcfg.CapabilityVersion
|
version tailcfg.CapabilityVersion
|
||||||
created time.Time
|
created time.Time
|
||||||
lastUsed atomic.Int64 // Unix timestamp of last successful send
|
lastUsed atomic.Int64 // Unix timestamp of last successful send
|
||||||
|
closed atomic.Bool // Indicates if this connection has been closed
|
||||||
}
|
}
|
||||||
|
|
||||||
// multiChannelNodeConn manages multiple concurrent connections for a single node.
|
// multiChannelNodeConn manages multiple concurrent connections for a single node.
|
||||||
@ -488,6 +488,9 @@ func (mc *multiChannelNodeConn) close() {
|
|||||||
defer mc.mutex.Unlock()
|
defer mc.mutex.Unlock()
|
||||||
|
|
||||||
for _, conn := range mc.connections {
|
for _, conn := range mc.connections {
|
||||||
|
// Mark as closed before closing the channel to prevent
|
||||||
|
// send on closed channel panics from concurrent workers
|
||||||
|
conn.closed.Store(true)
|
||||||
close(conn.c)
|
close(conn.c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -620,6 +623,12 @@ func (entry *connectionEntry) send(data *tailcfg.MapResponse) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the connection has been closed to prevent send on closed channel panic.
|
||||||
|
// This can happen during shutdown when Close() is called while workers are still processing.
|
||||||
|
if entry.closed.Load() {
|
||||||
|
return fmt.Errorf("connection %s: %w", entry.id, errConnectionClosed)
|
||||||
|
}
|
||||||
|
|
||||||
// Use a short timeout to detect stale connections where the client isn't reading the channel.
|
// Use a short timeout to detect stale connections where the client isn't reading the channel.
|
||||||
// This is critical for detecting Docker containers that are forcefully terminated
|
// This is critical for detecting Docker containers that are forcefully terminated
|
||||||
// but still have channels that appear open.
|
// but still have channels that appear open.
|
||||||
|
|||||||
@ -147,12 +147,12 @@ type node struct {
|
|||||||
n *types.Node
|
n *types.Node
|
||||||
ch chan *tailcfg.MapResponse
|
ch chan *tailcfg.MapResponse
|
||||||
|
|
||||||
// Update tracking
|
// Update tracking (all accessed atomically for thread safety)
|
||||||
updateCount int64
|
updateCount int64
|
||||||
patchCount int64
|
patchCount int64
|
||||||
fullCount int64
|
fullCount int64
|
||||||
maxPeersCount int
|
maxPeersCount atomic.Int64
|
||||||
lastPeerCount int
|
lastPeerCount atomic.Int64
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
stopped chan struct{}
|
stopped chan struct{}
|
||||||
}
|
}
|
||||||
@ -422,18 +422,32 @@ func (n *node) start() {
|
|||||||
// Track update types
|
// Track update types
|
||||||
if info.IsFull {
|
if info.IsFull {
|
||||||
atomic.AddInt64(&n.fullCount, 1)
|
atomic.AddInt64(&n.fullCount, 1)
|
||||||
n.lastPeerCount = info.PeerCount
|
n.lastPeerCount.Store(int64(info.PeerCount))
|
||||||
// Update max peers seen
|
// Update max peers seen using compare-and-swap for thread safety
|
||||||
if info.PeerCount > n.maxPeersCount {
|
for {
|
||||||
n.maxPeersCount = info.PeerCount
|
current := n.maxPeersCount.Load()
|
||||||
|
if int64(info.PeerCount) <= current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.maxPeersCount.CompareAndSwap(current, int64(info.PeerCount)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.IsPatch {
|
if info.IsPatch {
|
||||||
atomic.AddInt64(&n.patchCount, 1)
|
atomic.AddInt64(&n.patchCount, 1)
|
||||||
// For patches, we track how many patch items
|
// For patches, we track how many patch items using compare-and-swap
|
||||||
if info.PatchCount > n.maxPeersCount {
|
for {
|
||||||
n.maxPeersCount = info.PatchCount
|
current := n.maxPeersCount.Load()
|
||||||
|
if int64(info.PatchCount) <= current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.maxPeersCount.CompareAndSwap(current, int64(info.PatchCount)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -465,8 +479,8 @@ func (n *node) cleanup() NodeStats {
|
|||||||
TotalUpdates: atomic.LoadInt64(&n.updateCount),
|
TotalUpdates: atomic.LoadInt64(&n.updateCount),
|
||||||
PatchUpdates: atomic.LoadInt64(&n.patchCount),
|
PatchUpdates: atomic.LoadInt64(&n.patchCount),
|
||||||
FullUpdates: atomic.LoadInt64(&n.fullCount),
|
FullUpdates: atomic.LoadInt64(&n.fullCount),
|
||||||
MaxPeersSeen: n.maxPeersCount,
|
MaxPeersSeen: int(n.maxPeersCount.Load()),
|
||||||
LastPeerCount: n.lastPeerCount,
|
LastPeerCount: int(n.lastPeerCount.Load()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -665,7 +679,8 @@ func TestBatcherScalabilityAllToAll(t *testing.T) {
|
|||||||
connectedCount := 0
|
connectedCount := 0
|
||||||
for i := range allNodes {
|
for i := range allNodes {
|
||||||
node := &allNodes[i]
|
node := &allNodes[i]
|
||||||
currentMaxPeers := node.maxPeersCount
|
|
||||||
|
currentMaxPeers := int(node.maxPeersCount.Load())
|
||||||
if currentMaxPeers >= expectedPeers {
|
if currentMaxPeers >= expectedPeers {
|
||||||
connectedCount++
|
connectedCount++
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user