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
@@ -1,11 +1,76 @@
package mapper
import (
"errors"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types/change"
"tailscale.com/tailcfg"
)
// TestUnreadyConnectionDefersBroadcastsUntilInitialMap pins the ordering fix
// for the "initial MapResponse lacked Node" client failure: a connection
// registered by [Batcher.AddNode] but still waiting for its initial map must
// not receive broadcast deltas — a delta as the stream's first frame makes the
// Tailscale client tear down the poll. The change is requeued, not dropped,
// because the in-flight initial map may have been generated from a snapshot
// older than the change.
func TestUnreadyConnectionDefersBroadcastsUntilInitialMap(t *testing.T) {
testData, cleanup := setupBatcherWithTestData(t, NewBatcherAndMapper, 1, 2, normalBufferSize)
defer cleanup()
batcher := testData.Batcher.Batcher
peerNode := &testData.Nodes[0]
targetNode := &testData.Nodes[1]
// Register the target's connection by hand in the state AddNode leaves it
// in between registering the channel and delivering the initial map.
nc := newMultiChannelNodeConn(targetNode.n.ID, batcher.mapper)
entry := &connectionEntry{
id: "test-unready",
c: targetNode.ch,
version: tailcfg.CapabilityVersion(100),
created: time.Now(),
}
entry.pendingInitial.Store(true)
nc.addConnection(entry)
batcher.nodes.Store(targetNode.n.ID, nc)
// A broadcast lands while the initial map is still in flight: nothing may
// reach the channel, and the caller must be told to retry
// (the worker prepends such changes back onto pending).
retryChange := change.NodeAdded(peerNode.n.ID)
err := nc.change(retryChange)
if !errors.Is(err, errNoReadyConnections) {
t.Fatalf("change on unready connection: want errNoReadyConnections, got %v", err)
}
select {
case resp := <-targetNode.ch:
t.Fatalf("unready connection received a frame before its initial map: %+v", resp)
default:
}
// Once the initial map is delivered, the retried change goes out.
entry.pendingInitial.Store(false)
err = nc.change(retryChange)
if err != nil {
t.Fatalf("change on ready connection: %v", err)
}
select {
case resp := <-targetNode.ch:
if len(resp.PeersChanged) == 0 {
t.Fatalf("expected PeersChanged delta after readiness, got %+v", resp)
}
default:
t.Fatal("ready connection did not receive the retried change")
}
}
// TestSyncInitialMapNoPhantomPeersOnTimeout ensures the synchronous initial-map
// path does not record peers as sent until the map is actually delivered. When
// the AddNode channel send times out, the client received nothing, so