mapper: gate broadcast sends until a connection's initial map is delivered

AddNode registers the connection before sending the initial map, so a
batched delta could land first and become the stream's first frame;
Tailscale clients then kill the poll with "initial MapResponse lacked
Node" and a retry loop under steady change traffic leaves the netmap
empty. Skip connections awaiting their initial map and retry the
change next tick — the in-flight map may predate it, so it cannot be
dropped. Retries are prepended to keep patch order.
This commit is contained in:
Kristoffer Dalby
2026-06-10 13:42:05 +00:00
committed by Kristoffer Dalby
parent 7918187e7a
commit f4eeb94b1c
3 changed files with 141 additions and 3 deletions
+47 -3
View File
@@ -23,6 +23,16 @@ import (
// after this error because the data was never delivered to any client.
var errNoActiveConnections = errors.New("no active connections")
// errNoReadyConnections is returned by [multiChannelNodeConn.send] when the
// node's only connections are still waiting for their initial map
// ([Batcher.AddNode] has registered them but not yet delivered the first full
// response). Sending a delta now would make it the stream's first frame, which
// Tailscale clients reject ("initial MapResponse lacked Node") — tearing down
// the poll. Unlike [errNoActiveConnections], the change must be retried: the
// in-flight initial map may have been generated from a snapshot older than
// the change, so dropping it would lose the update.
var errNoReadyConnections = errors.New("no connections ready for updates")
// connectionEntry represents a single connection to a node.
type connectionEntry struct {
id string // unique connection ID
@@ -32,6 +42,13 @@ type connectionEntry struct {
stop func()
lastUsed atomic.Int64 // Unix timestamp of last successful send
closed atomic.Bool // Indicates if this connection has been closed
// pendingInitial is set by [Batcher.AddNode] while this
// connection's initial map is still in flight, and cleared once it
// is delivered. Broadcast sends skip such connections so a delta
// can never become the stream's first frame ahead of the initial
// map. The zero value means the connection is ready.
pendingInitial atomic.Bool
}
// multiChannelNodeConn manages multiple concurrent connections for a single node.
@@ -225,6 +242,17 @@ func (mc *multiChannelNodeConn) appendPending(changes ...change.Change) {
mc.pendingMu.Unlock()
}
// prependPending puts changes at the head of the pending list, ahead of
// anything queued since. Used to retry changes that could not be
// delivered yet (initial map in flight): they were emitted before the
// currently pending ones, and order matters for stateful patches like
// online/offline.
func (mc *multiChannelNodeConn) prependPending(changes ...change.Change) {
mc.pendingMu.Lock()
mc.pending = append(changes, mc.pending...)
mc.pendingMu.Unlock()
}
// drainPending atomically removes and returns all pending changes.
// Returns nil if there are no pending changes.
func (mc *multiChannelNodeConn) drainPending() []change.Change {
@@ -260,11 +288,27 @@ func (mc *multiChannelNodeConn) send(data *tailcfg.MapResponse) error {
return errNoActiveConnections
}
// Copy the slice so we can release the read lock before sending.
snapshot := make([]*connectionEntry, len(mc.connections))
copy(snapshot, mc.connections)
// Copy only connections whose initial map has been delivered.
// A connection still awaiting its initial map receives one
// (generated from the current snapshot) from [Batcher.AddNode];
// pushing this update at it now would deliver a delta as the
// stream's first frame.
snapshot := make([]*connectionEntry, 0, len(mc.connections))
for _, conn := range mc.connections {
if !conn.pendingInitial.Load() {
snapshot = append(snapshot, conn)
}
}
mc.mutex.RUnlock()
if len(snapshot) == 0 {
mc.log.Trace().
Msg("send: connections present but none ready, requeue")
return errNoReadyConnections
}
mc.log.Trace().
Int("total_connections", len(snapshot)).
Msg("send: broadcasting")