Skip to content

Commit a96ef43

Browse files
committed
control/controlclient,ipn/ipnlocal: replace State enum with boolean flags
Remove the State enum (StateNew, StateNotAuthenticated, etc.) from controlclient and replace it with two explicit boolean fields: - LoginFinished: indicates successful authentication - Synced: indicates we've received at least one netmap This makes the state more composable and easier to reason about, as multiple conditions can be true independently rather than being encoded in a single enum value. The State enum was originally intended as the state machine for the whole client, but that abstraction moved to ipn.Backend long ago. This change continues moving away from the legacy state machine by representing state as a combination of independent facts. Also adds test helpers in ipnlocal that check independent, observable facts (hasValidNetMap, needsLogin, etc.) rather than relying on derived state enums, making tests more robust. Updates tailscale#12639 Signed-off-by: James Tucker <[email protected]>
1 parent c5919b4 commit a96ef43

File tree

5 files changed

+224
-193
lines changed

5 files changed

+224
-193
lines changed

control/controlclient/auto.go

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ type Auto struct {
138138
loggedIn bool // true if currently logged in
139139
loginGoal *LoginGoal // non-nil if some login activity is desired
140140
inMapPoll bool // true once we get the first MapResponse in a stream; false when HTTP response ends
141-
state State // TODO(bradfitz): delete this, make it computed by method from other state
142141

143142
authCtx context.Context // context used for auth requests
144143
mapCtx context.Context // context used for netmap and update requests
@@ -296,10 +295,11 @@ func (c *Auto) authRoutine() {
296295
c.mu.Lock()
297296
goal := c.loginGoal
298297
ctx := c.authCtx
298+
loggedIn := c.loggedIn
299299
if goal != nil {
300-
c.logf("[v1] authRoutine: %s; wantLoggedIn=%v", c.state, true)
300+
c.logf("[v1] authRoutine: loggedIn=%v; wantLoggedIn=%v", loggedIn, true)
301301
} else {
302-
c.logf("[v1] authRoutine: %s; goal=nil paused=%v", c.state, c.paused)
302+
c.logf("[v1] authRoutine: loggedIn=%v; goal=nil paused=%v", loggedIn, c.paused)
303303
}
304304
c.mu.Unlock()
305305

@@ -322,11 +322,6 @@ func (c *Auto) authRoutine() {
322322

323323
c.mu.Lock()
324324
c.urlToVisit = goal.url
325-
if goal.url != "" {
326-
c.state = StateURLVisitRequired
327-
} else {
328-
c.state = StateAuthenticating
329-
}
330325
c.mu.Unlock()
331326

332327
var url string
@@ -360,7 +355,6 @@ func (c *Auto) authRoutine() {
360355
flags: LoginDefault,
361356
url: url,
362357
}
363-
c.state = StateURLVisitRequired
364358
c.mu.Unlock()
365359

366360
c.sendStatus("authRoutine-url", err, url, nil)
@@ -380,7 +374,6 @@ func (c *Auto) authRoutine() {
380374
c.urlToVisit = ""
381375
c.loggedIn = true
382376
c.loginGoal = nil
383-
c.state = StateAuthenticated
384377
c.mu.Unlock()
385378

386379
c.sendStatus("authRoutine-success", nil, "", nil)
@@ -431,12 +424,9 @@ func (mrs mapRoutineState) UpdateFullNetmap(nm *netmap.NetworkMap) {
431424

432425
c.mu.Lock()
433426
c.inMapPoll = true
434-
if c.loggedIn {
435-
c.state = StateSynchronized
436-
}
437427
c.expiry = nm.Expiry
438428
stillAuthed := c.loggedIn
439-
c.logf("[v1] mapRoutine: netmap received: %s", c.state)
429+
c.logf("[v1] mapRoutine: netmap received: loggedIn=%v inMapPoll=true", stillAuthed)
440430
c.mu.Unlock()
441431

442432
if stillAuthed {
@@ -484,8 +474,8 @@ func (c *Auto) mapRoutine() {
484474
}
485475

486476
c.mu.Lock()
487-
c.logf("[v1] mapRoutine: %s", c.state)
488477
loggedIn := c.loggedIn
478+
c.logf("[v1] mapRoutine: loggedIn=%v", loggedIn)
489479
ctx := c.mapCtx
490480
c.mu.Unlock()
491481

@@ -516,9 +506,6 @@ func (c *Auto) mapRoutine() {
516506
c.direct.health.SetOutOfPollNetMap()
517507
c.mu.Lock()
518508
c.inMapPoll = false
519-
if c.state == StateSynchronized {
520-
c.state = StateAuthenticated
521-
}
522509
paused := c.paused
523510
c.mu.Unlock()
524511

@@ -584,12 +571,12 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
584571
c.mu.Unlock()
585572
return
586573
}
587-
state := c.state
588574
loggedIn := c.loggedIn
589575
inMapPoll := c.inMapPoll
576+
loginGoal := c.loginGoal
590577
c.mu.Unlock()
591578

592-
c.logf("[v1] sendStatus: %s: %v", who, state)
579+
c.logf("[v1] sendStatus: %s: loggedIn=%v inMapPoll=%v", who, loggedIn, inMapPoll)
593580

594581
var p persist.PersistView
595582
if nm != nil && loggedIn && inMapPoll {
@@ -600,11 +587,12 @@ func (c *Auto) sendStatus(who string, err error, url string, nm *netmap.NetworkM
600587
nm = nil
601588
}
602589
newSt := &Status{
603-
URL: url,
604-
Persist: p,
605-
NetMap: nm,
606-
Err: err,
607-
state: state,
590+
URL: url,
591+
Persist: p,
592+
NetMap: nm,
593+
Err: err,
594+
LoggedIn: loggedIn && loginGoal == nil,
595+
InMapPoll: inMapPoll,
608596
}
609597

610598
if c.observer == nil {
@@ -667,14 +655,15 @@ func canSkipStatus(s1, s2 *Status) bool {
667655
// we can't skip it.
668656
return false
669657
}
670-
if s1.Err != nil || s1.URL != "" {
671-
// If s1 has an error or a URL, we shouldn't skip it, lest the error go
672-
// away in s2 or in-between. We want to make sure all the subsystems see
673-
// it. Plus there aren't many of these, so not worth skipping.
658+
if s1.Err != nil || s1.URL != "" || s1.LoggedIn {
659+
// If s1 has an error, a URL, or LoginFinished set, we shouldn't skip it,
660+
// lest the error go away in s2 or in-between. We want to make sure all
661+
// the subsystems see it. Plus there aren't many of these, so not worth
662+
// skipping.
674663
return false
675664
}
676-
if !s1.Persist.Equals(s2.Persist) || s1.state != s2.state {
677-
// If s1 has a different Persist or state than s2,
665+
if !s1.Persist.Equals(s2.Persist) || s1.LoggedIn != s2.LoggedIn || s1.InMapPoll != s2.InMapPoll || s1.URL != s2.URL {
666+
// If s1 has a different Persist, LoginFinished, Synced, or URL than s2,
678667
// don't skip it. We only care about skipping the typical
679668
// entries where the only difference is the NetMap.
680669
return false
@@ -736,7 +725,6 @@ func (c *Auto) Logout(ctx context.Context) error {
736725
}
737726
c.mu.Lock()
738727
c.loggedIn = false
739-
c.state = StateNotAuthenticated
740728
c.cancelAuthCtxLocked()
741729
c.cancelMapCtxLocked()
742730
c.mu.Unlock()

control/controlclient/controlclient_test.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"net/netip"
1616
"net/url"
1717
"reflect"
18-
"slices"
1918
"sync/atomic"
2019
"testing"
2120
"time"
@@ -49,7 +48,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
4948

5049
func TestStatusEqual(t *testing.T) {
5150
// Verify that the Equal method stays in sync with reality
52-
equalHandles := []string{"Err", "URL", "NetMap", "Persist", "state"}
51+
equalHandles := []string{"Err", "URL", "LoggedIn", "InMapPoll", "NetMap", "Persist"}
5352
if have := fieldsOf(reflect.TypeFor[Status]()); !reflect.DeepEqual(have, equalHandles) {
5453
t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
5554
have, equalHandles)
@@ -81,7 +80,7 @@ func TestStatusEqual(t *testing.T) {
8180
},
8281
{
8382
&Status{},
84-
&Status{state: StateAuthenticated},
83+
&Status{LoggedIn: true, Persist: new(persist.Persist).View()},
8584
false,
8685
},
8786
}
@@ -135,8 +134,20 @@ func TestCanSkipStatus(t *testing.T) {
135134
want: false,
136135
},
137136
{
138-
name: "s1-state-diff",
139-
s1: &Status{state: 123, NetMap: nm1},
137+
name: "s1-login-finished-diff",
138+
s1: &Status{LoggedIn: true, Persist: new(persist.Persist).View(), NetMap: nm1},
139+
s2: &Status{NetMap: nm2},
140+
want: false,
141+
},
142+
{
143+
name: "s1-login-finished",
144+
s1: &Status{LoggedIn: true, Persist: new(persist.Persist).View(), NetMap: nm1},
145+
s2: &Status{NetMap: nm2},
146+
want: false,
147+
},
148+
{
149+
name: "s1-synced-diff",
150+
s1: &Status{InMapPoll: true, LoggedIn: true, Persist: new(persist.Persist).View(), NetMap: nm1},
140151
s2: &Status{NetMap: nm2},
141152
want: false,
142153
},
@@ -167,10 +178,11 @@ func TestCanSkipStatus(t *testing.T) {
167178
})
168179
}
169180

170-
want := []string{"Err", "URL", "NetMap", "Persist", "state"}
171-
if f := fieldsOf(reflect.TypeFor[Status]()); !slices.Equal(f, want) {
172-
t.Errorf("Status fields = %q; this code was only written to handle fields %q", f, want)
181+
coveredFields := []string{"Err", "URL", "LoggedIn", "InMapPoll", "NetMap", "Persist"}
182+
if have := fieldsOf(reflect.TypeFor[Status]()); !reflect.DeepEqual(have, coveredFields) {
183+
t.Errorf("Status fields = %q; this code was only written to handle fields %q", have, coveredFields)
173184
}
185+
174186
}
175187

176188
func TestRetryableErrors(t *testing.T) {

control/controlclient/status.go

Lines changed: 10 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -4,66 +4,13 @@
44
package controlclient
55

66
import (
7-
"encoding/json"
8-
"fmt"
97
"reflect"
108

119
"tailscale.com/types/netmap"
1210
"tailscale.com/types/persist"
1311
"tailscale.com/types/structs"
1412
)
1513

16-
// State is the high-level state of the client. It is used only in
17-
// unit tests for proper sequencing, don't depend on it anywhere else.
18-
//
19-
// TODO(apenwarr): eliminate the state, as it's now obsolete.
20-
//
21-
// apenwarr: Historical note: controlclient.Auto was originally
22-
// intended to be the state machine for the whole tailscale client, but that
23-
// turned out to not be the right abstraction layer, and it moved to
24-
// ipn.Backend. Since ipn.Backend now has a state machine, it would be
25-
// much better if controlclient could be a simple stateless API. But the
26-
// current server-side API (two interlocking polling https calls) makes that
27-
// very hard to implement. A server side API change could untangle this and
28-
// remove all the statefulness.
29-
type State int
30-
31-
const (
32-
StateNew = State(iota)
33-
StateNotAuthenticated
34-
StateAuthenticating
35-
StateURLVisitRequired
36-
StateAuthenticated
37-
StateSynchronized // connected and received map update
38-
)
39-
40-
func (s State) AppendText(b []byte) ([]byte, error) {
41-
return append(b, s.String()...), nil
42-
}
43-
44-
func (s State) MarshalText() ([]byte, error) {
45-
return []byte(s.String()), nil
46-
}
47-
48-
func (s State) String() string {
49-
switch s {
50-
case StateNew:
51-
return "state:new"
52-
case StateNotAuthenticated:
53-
return "state:not-authenticated"
54-
case StateAuthenticating:
55-
return "state:authenticating"
56-
case StateURLVisitRequired:
57-
return "state:url-visit-required"
58-
case StateAuthenticated:
59-
return "state:authenticated"
60-
case StateSynchronized:
61-
return "state:synchronized"
62-
default:
63-
return fmt.Sprintf("state:unknown:%d", int(s))
64-
}
65-
}
66-
6714
type Status struct {
6815
_ structs.Incomparable
6916

@@ -76,33 +23,23 @@ type Status struct {
7623
// URL, if non-empty, is the interactive URL to visit to finish logging in.
7724
URL string
7825

26+
// LoggedIn, if true, indicates that serveRegister has completed and no
27+
// other login change is in progress.
28+
LoggedIn bool
29+
30+
// InMapPoll, if true, indicates that we've received at least one netmap
31+
// and are connected to receive updates.
32+
InMapPoll bool
33+
7934
// NetMap is the latest server-pushed state of the tailnet network.
8035
NetMap *netmap.NetworkMap
8136

8237
// Persist, when Valid, is the locally persisted configuration.
8338
//
8439
// TODO(bradfitz,maisem): clarify this.
8540
Persist persist.PersistView
86-
87-
// state is the internal state. It should not be exposed outside this
88-
// package, but we have some automated tests elsewhere that need to
89-
// use it via the StateForTest accessor.
90-
// TODO(apenwarr): Unexport or remove these.
91-
state State
9241
}
9342

94-
// LoginFinished reports whether the controlclient is in its "StateAuthenticated"
95-
// state where it's in a happy register state but not yet in a map poll.
96-
//
97-
// TODO(bradfitz): delete this and everything around Status.state.
98-
func (s *Status) LoginFinished() bool { return s.state == StateAuthenticated }
99-
100-
// StateForTest returns the internal state of s for tests only.
101-
func (s *Status) StateForTest() State { return s.state }
102-
103-
// SetStateForTest sets the internal state of s for tests only.
104-
func (s *Status) SetStateForTest(state State) { s.state = state }
105-
10643
// Equal reports whether s and s2 are equal.
10744
func (s *Status) Equal(s2 *Status) bool {
10845
if s == nil && s2 == nil {
@@ -111,15 +48,8 @@ func (s *Status) Equal(s2 *Status) bool {
11148
return s != nil && s2 != nil &&
11249
s.Err == s2.Err &&
11350
s.URL == s2.URL &&
114-
s.state == s2.state &&
51+
s.LoggedIn == s2.LoggedIn &&
52+
s.InMapPoll == s2.InMapPoll &&
11553
reflect.DeepEqual(s.Persist, s2.Persist) &&
11654
reflect.DeepEqual(s.NetMap, s2.NetMap)
11755
}
118-
119-
func (s Status) String() string {
120-
b, err := json.MarshalIndent(s, "", "\t")
121-
if err != nil {
122-
panic(err)
123-
}
124-
return s.state.String() + " " + string(b)
125-
}

ipn/ipnlocal/local.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1623,7 +1623,7 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
16231623
b.blockEngineUpdatesLocked(false)
16241624
}
16251625

1626-
if st.LoginFinished() && (wasBlocked || authWasInProgress) {
1626+
if st.LoggedIn && (wasBlocked || authWasInProgress) {
16271627
if wasBlocked {
16281628
// Auth completed, unblock the engine
16291629
b.blockEngineUpdatesLocked(false)
@@ -1658,8 +1658,8 @@ func (b *LocalBackend) SetControlClientStatus(c controlclient.Client, st control
16581658
prefs.Persist = st.Persist.AsStruct()
16591659
}
16601660
}
1661-
if st.LoginFinished() {
1662-
if b.authURL != "" {
1661+
if st.LoggedIn {
1662+
if authWasInProgress {
16631663
b.resetAuthURLLocked()
16641664
// Interactive login finished successfully (URL visited).
16651665
// After an interactive login, the user always wants

0 commit comments

Comments
 (0)