WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
8 changed files with 206 additions and 25 deletions
Showing only changes of commit edcc2c94d9 - Show all commits
+19
View File
@@ -158,6 +158,25 @@ const (
NotifyPeerPatches NotifyWatchOpt = 1 << 15
)
// NotifyRateLimitIncompatibleBits is the set of new-style IPN bus
// subscription bits that cannot be combined with [NotifyRateLimit].
//
// Those bits describe stateful delta streams. Randomly delaying or merging
// messages in those streams would break the consumer's ability to maintain a
// coherent local view.
const NotifyRateLimitIncompatibleBits = NotifyPeerChanges | NotifyNoNetMap | NotifyInitialStatus | NotifyPeerPatches
// ValidateNotifyWatchOpt reports whether mask is a valid WatchIPNBus
// subscription mask.
func ValidateNotifyWatchOpt(mask NotifyWatchOpt) error {
if mask&NotifyRateLimit != 0 {
if bad := mask & NotifyRateLimitIncompatibleBits; bad != 0 {
return fmt.Errorf("NotifyRateLimit is incompatible with new-style IPN bus subscription bits %v", bad)
}
}
return nil
}
// Notify is a communication from a backend (e.g. tailscaled) to a frontend
// (cmd/tailscale, iOS, macOS, Win Tasktray).
// In any given notification, any or all of these may be nil, meaning
+46
View File
@@ -40,3 +40,49 @@ func TestNotifyString(t *testing.T) {
})
}
}
func TestValidateNotifyWatchOpt(t *testing.T) {
tests := []struct {
name string
mask NotifyWatchOpt
wantErr bool
}{
{
name: "legacy-rate-limit-only",
mask: NotifyRateLimit,
},
{
name: "peer-changes-without-rate-limit",
mask: NotifyPeerChanges | NotifyPeerPatches | NotifyNoNetMap | NotifyInitialStatus,
},
{
name: "rate-limit-with-peer-changes",
mask: NotifyRateLimit | NotifyPeerChanges,
wantErr: true,
},
{
name: "rate-limit-with-peer-patches",
mask: NotifyRateLimit | NotifyPeerPatches,
wantErr: true,
},
{
name: "rate-limit-with-no-netmap",
mask: NotifyRateLimit | NotifyNoNetMap,
wantErr: true,
},
{
name: "rate-limit-with-initial-status",
mask: NotifyRateLimit | NotifyInitialStatus,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateNotifyWatchOpt(tt.mask)
if gotErr := err != nil; gotErr != tt.wantErr {
t.Fatalf("ValidateNotifyWatchOpt(%v) error = %v; wantErr %v", tt.mask, err, tt.wantErr)
}
})
}
}
+9 -1
View File
@@ -218,7 +218,11 @@ func mergePeerChangeForIpnBus(old, new *tailcfg.PeerChange) *tailcfg.PeerChange
// should be sent on the IPN bus immediately (e.g. to GUIs) without
// rate limiting it for a few seconds.
//
// PeerChanges and Engine are the only "boring" (rate-limitable) fields.
// This is only used for legacy [ipn.NotifyRateLimit] subscribers. New-style
// subscriptions that receive delta streams are rejected by
// [ipn.ValidateNotifyWatchOpt] when combined with NotifyRateLimit.
//
// Legacy NetMap and Engine are the only "boring" (rate-limitable) fields.
func isNotableNotify(n *ipn.Notify) bool {
if n == nil {
return false
@@ -233,6 +237,10 @@ func isNotableNotify(n *ipn.Notify) bool {
n.LoginFinished != nil ||
n.SelfChange != nil ||
n.InitialStatus != nil ||
len(n.PeerChangedPatch) > 0 ||
len(n.PeersChanged) > 0 ||
len(n.PeersRemoved) > 0 ||
len(n.UserProfiles) > 0 ||
!n.DriveShares.IsNil() ||
n.Health != nil ||
len(n.IncomingFiles) > 0 ||
+20 -19
View File
@@ -30,10 +30,10 @@ func TestIsNotableNotify(t *testing.T) {
{"empty", &ipn.Notify{}, false},
{"version", &ipn.Notify{Version: "foo"}, false},
{"netmap", &ipn.Notify{NetMap: new(netmap.NetworkMap)}, false},
{"peerchanges", &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{}}}, false},
{"peerschanged", &ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, false},
{"peersremoved", &ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, false},
{"userprofiles", &ipn.Notify{UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{1: (&tailcfg.UserProfile{}).View()}}, false},
{"peerchanges", &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{}}}, true},
{"peerschanged", &ipn.Notify{PeersChanged: []*tailcfg.Node{{}}}, true},
{"peersremoved", &ipn.Notify{PeersRemoved: []tailcfg.NodeID{1}}, true},
{"userprofiles", &ipn.Notify{UserProfiles: map[tailcfg.UserID]tailcfg.UserProfileView{1: (&tailcfg.UserProfile{}).View()}}, true},
{"engine", &ipn.Notify{Engine: new(ipn.EngineStatus)}, false},
{"selfchange", &ipn.Notify{SelfChange: &tailcfg.Node{}}, true},
}
@@ -126,20 +126,18 @@ func (st *rateLimitingBusSenderTester) advance(d time.Duration) {
}
func TestRateLimitingBusSender(t *testing.T) {
// Both share NodeID 1 so merge collapses to a single PeerChange and
// the later one (nm2) wins.
nm1 := &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{NodeID: 1, DERPRegion: 1}}}
nm2 := &ipn.Notify{PeerChangedPatch: []*tailcfg.PeerChange{{NodeID: 1, DERPRegion: 2}}}
ver1 := &ipn.Notify{Version: "1"}
ver2 := &ipn.Notify{Version: "2"}
eng1 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
eng2 := &ipn.Notify{Engine: new(ipn.EngineStatus)}
t.Run("unbuffered", func(t *testing.T) {
st := &rateLimitingBusSenderTester{tb: t}
st.send(nm1)
st.send(nm2)
st.send(ver1)
st.send(ver2)
st.send(eng1)
st.send(eng2)
if !slices.Equal(st.got, []*ipn.Notify{nm1, nm2, eng1, eng2}) {
if !slices.Equal(st.got, []*ipn.Notify{ver1, ver2, eng1, eng2}) {
t.Errorf("got %d items; want 4 specific ones, unmodified", len(st.got))
}
})
@@ -152,8 +150,8 @@ func TestRateLimitingBusSender(t *testing.T) {
if len(st.got) != 1 {
t.Fatalf("got %d items; expected 1 (first to flush immediately)", len(st.got))
}
st.send(nm1)
st.send(nm2)
st.send(ver1)
st.send(ver2)
st.send(eng1)
st.send(eng2)
if len(st.got) != 1 {
@@ -168,8 +166,8 @@ func TestRateLimitingBusSender(t *testing.T) {
t.Fatalf("got %d items; want 2", len(st.got))
}
gotn := st.got[1]
if !reflect.DeepEqual(gotn.PeerChangedPatch, nm2.PeerChangedPatch) {
t.Errorf("got wrong PeerChangedPatch; got %v want %v", gotn.PeerChangedPatch, nm2.PeerChangedPatch)
if gotn.Version != ver1.Version {
t.Errorf("got wrong Version; got %q want %q", gotn.Version, ver1.Version)
}
if gotn.Engine != eng2.Engine {
t.Errorf("got wrong Engine; got %p", gotn.Engine)
@@ -206,15 +204,18 @@ func TestRateLimitingBusSender(t *testing.T) {
incoming := make(chan *ipn.Notify, 2)
go func() {
incoming <- nm1
incoming <- ver1
waitSend()
incoming <- nm2
incoming <- eng2
waitSend()
st.advance(5 * time.Second)
select {
case n := <-flushc:
if !reflect.DeepEqual(n.PeerChangedPatch, nm2.PeerChangedPatch) {
t.Errorf("got wrong PeerChangedPatch; got %v want %v", n.PeerChangedPatch, nm2.PeerChangedPatch)
if n.Version != ver1.Version {
t.Errorf("got wrong Version; got %q want %q", n.Version, ver1.Version)
}
if n.Engine != eng2.Engine {
t.Errorf("got wrong Engine; got %p", n.Engine)
}
case <-time.After(10 * time.Second):
t.Error("timeout")
+36 -4
View File
@@ -3587,17 +3587,24 @@ func applyConfigToHostinfo(hi *tailcfg.Hostinfo, c *conffile.Config) {
// called with non-nil pointers. The caller must not modify roNotify. If
// fn returns false, the watch also stops.
//
// Failure to consume many notifications in a row will result in dropped
// notifications. There is currently (2022-11-22) no mechanism provided to
// detect when a message has been dropped.
// Failure to consume many notifications in a row will result in one final
// notification with ErrMessage set, followed by the watch closing.
func (b *LocalBackend) WatchNotifications(ctx context.Context, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
b.WatchNotificationsAs(ctx, nil, mask, onWatchAdded, fn)
}
const watchIPNBusFellBehindMessage = "IPN bus consumer fell behind; closing watch"
// WatchNotificationsAs is like [LocalBackend.WatchNotifications] but takes an [ipnauth.Actor]
// as an additional parameter. If non-nil, the specified callback is invoked
// only for notifications relevant to this actor.
func (b *LocalBackend) WatchNotificationsAs(ctx context.Context, actor ipnauth.Actor, mask ipn.NotifyWatchOpt, onWatchAdded func(), fn func(roNotify *ipn.Notify) (keepGoing bool)) {
if err := ipn.ValidateNotifyWatchOpt(mask); err != nil {
msg := err.Error()
fn(&ipn.Notify{Version: version.Long(), ErrMessage: &msg})
return
}
ch := make(chan *ipn.Notify, 128)
sessionID := rands.HexString(16)
if mask&ipn.NotifyHealthActions == 0 {
@@ -3916,7 +3923,32 @@ func (b *LocalBackend) sendToLocked(n ipn.Notify, recipient notificationTarget)
select {
case sess.ch <- nForSess:
default:
// Drop the notification if the channel is full.
b.closeLaggingWatchSessionLocked(sess)
}
}
}
// closeLaggingWatchSessionLocked removes sess from the active watcher set and
// arranges for its consumer to receive one final terminal error notification.
//
// b.mu must be held.
func (b *LocalBackend) closeLaggingWatchSessionLocked(sess *watchSession) {
delete(b.notifyWatchers, sess.sessionID)
// The session already fell behind, so the queued delta stream is not
// trustworthy. Drop queued messages and replace them with a terminal
// notification.
for {
select {
case <-sess.ch:
default:
msg := watchIPNBusFellBehindMessage
sess.ch <- &ipn.Notify{
Version: version.Long(),
ErrMessage: &msg,
}
close(sess.ch)
return
}
}
}
+60
View File
@@ -2116,6 +2116,66 @@ func TestWatchNotificationsCallbacks(t *testing.T) {
}
}
func TestWatchNotificationsClosesSlowConsumer(t *testing.T) {
b := newTestLocalBackend(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
watchAdded := make(chan struct{})
firstNotify := make(chan struct{}, 1)
releaseFirstNotify := make(chan struct{})
terminalMessage := make(chan string, 1)
done := make(chan struct{})
go func() {
defer close(done)
b.WatchNotificationsAs(ctx, nil, 0, func() { close(watchAdded) }, func(n *ipn.Notify) bool {
if n.ErrMessage != nil {
terminalMessage <- *n.ErrMessage
return true
}
select {
case firstNotify <- struct{}{}:
<-releaseFirstNotify
default:
}
return true
})
}()
<-watchAdded
state := ipn.Running
b.send(ipn.Notify{State: &state})
select {
case <-firstNotify:
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for first notification")
}
// The watcher's callback is blocked on the first notification. Fill the
// 128-slot queue, then send one more notification to force overflow.
for range 129 {
b.send(ipn.Notify{State: &state})
}
close(releaseFirstNotify)
select {
case got := <-terminalMessage:
if got != watchIPNBusFellBehindMessage {
t.Fatalf("terminal message = %q; want %q", got, watchIPNBusFellBehindMessage)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for terminal notification")
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for watcher to close")
}
}
// TestNotifyForSessionPeerVisibility verifies the per-session masking
// logic in [LocalBackend.notifyForSessionLocked] for the
// NotifyPeerChanges / NotifyPeerPatches flag pair:
+4
View File
@@ -902,6 +902,10 @@ func (h *Handler) serveWatchIPNBus(w http.ResponseWriter, r *http.Request) {
}
mask = ipn.NotifyWatchOpt(v)
}
if err := ipn.ValidateNotifyWatchOpt(mask); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// NotifyInitialNetMap is permitted alongside NotifyPeerChanges /
// NotifyPeerPatches for backwards compatibility with clients that
// set both (e.g. the Apple client). On platforms where
+12 -1
View File
@@ -634,6 +634,7 @@ func TestServeWatchIPNBus(t *testing.T) {
tests := []struct {
desc string
permitRead, permitWrite bool
mask ipn.NotifyWatchOpt
wantStatus int
}{
{
@@ -654,6 +655,12 @@ func TestServeWatchIPNBus(t *testing.T) {
permitWrite: true,
wantStatus: http.StatusOK,
},
{
desc: "invalid-rate-limit-mask",
permitRead: true,
mask: ipn.NotifyRateLimit | ipn.NotifyPeerChanges,
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
@@ -668,7 +675,11 @@ func TestServeWatchIPNBus(t *testing.T) {
c := s.Client()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, ipn.NotifyInitialState), nil)
mask := tt.mask
if mask == 0 {
mask = ipn.NotifyInitialState
}
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/localapi/v0/watch-ipn-bus?mask=%d", s.URL, mask), nil)
if err != nil {
t.Fatal(err)
}