ipn/ipnserver: fix race condition where LocalBackend is reset after a different user connects

In this commit, we add a failing test to verify that ipn/ipnserver.Server correctly
sets and unsets the current user when two different clients send requests concurrently
(A sends request, B sends request, A's request completes, B's request completes).

The expectation is that the user who wins the race becomes the current user
from the LocalBackend's perspective, remaining in this state until they disconnect,
after which a different user should be able to connect and use the LocalBackend.

We then fix the second of two bugs in (*Server).addActiveHTTPRequest, where a race
condition causes the LocalBackend's state to be reset after a new client connects,
instead of after the last active request of the previous client completes and the server
becomes idle.

Fixes tailscale/corp#25804

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-01-13 18:20:09 -06:00 committed by Nick Khyl
parent f33f5f99c0
commit 6fac2903e1
2 changed files with 74 additions and 8 deletions

View File

@ -394,11 +394,14 @@ func (s *Server) addActiveHTTPRequest(req *http.Request, actor ipnauth.Actor) (o
onDone = func() {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.activeReqs, req)
remain := len(s.activeReqs)
s.mu.Unlock()
if len(s.activeReqs) != 0 {
// The server is not idle yet.
return
}
if remain == 0 && s.resetOnZero {
if s.resetOnZero {
if lb.InServerMode() {
s.logf("client disconnected; staying alive in server mode")
} else {
@ -408,11 +411,7 @@ func (s *Server) addActiveHTTPRequest(req *http.Request, actor ipnauth.Actor) (o
}
// Wake up callers waiting for the server to be idle:
if remain == 0 {
s.mu.Lock()
s.zeroReqWaiter.wakeAll()
s.mu.Unlock()
}
s.zeroReqWaiter.wakeAll()
}
return onDone, nil

View File

@ -12,6 +12,7 @@ import (
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"sync"
"sync/atomic"
"testing"
@ -187,6 +188,72 @@ func TestSequentialOSUserSwitchingOnWindows(t *testing.T) {
connectDisconnectAsUser("UserB")
}
func TestConcurrentOSUserSwitchingOnWindows(t *testing.T) {
enableLogging := false
setGOOSForTest(t, "windows")
ctx := context.Background()
server := startDefaultTestIPNServer(t, ctx, enableLogging)
connectDisconnectAsUser := func(name string) {
// User connects and starts watching the IPN bus.
client := server.getClientAs(name)
watcher, cancelWatcher := client.WatchIPNBus(ctx, ipn.NotifyInitialState)
defer cancelWatcher()
runtime.Gosched()
// Get the current user from the LocalBackend's perspective
// as soon as we're connected.
gotUID, gotActor := server.mustBackend().CurrentUserForTest()
// Wait for the first notification to arrive.
// It will either be the initial state we've requested via [ipn.NotifyInitialState],
// returned by an actual handler, or a "fake" notification sent by the server
// itself to indicate that it is being used by someone else.
n, err := watcher.Next()
if err != nil {
t.Fatal(err)
}
// If our user lost the race and the IPN is in use by another user,
// we should just return. For the sake of this test, we're not
// interested in waiting for the server to become idle.
if n.State != nil && *n.State == ipn.InUseOtherUser {
return
}
// Otherwise, our user should have been the current user since the time we connected.
if gotUID != client.User.UID {
t.Errorf("CurrentUser(Initial): got UID %q; want %q", gotUID, client.User.UID)
return
}
if gotActor, ok := gotActor.(*ipnauth.TestActor); !ok || *gotActor != *client.User {
t.Errorf("CurrentUser(Initial): got %v; want %v", gotActor, client.User)
return
}
// And should still be the current user (as they're still connected)...
server.checkCurrentUser(client.User)
}
numIterations := 10
for range numIterations {
numGoRoutines := 100
var wg sync.WaitGroup
wg.Add(numGoRoutines)
for i := range numGoRoutines {
// User logs in, uses Tailscale for a bit, then logs out
// in parallel with other users doing the same.
go func() {
defer wg.Done()
connectDisconnectAsUser("User-" + strconv.Itoa(i))
}()
}
wg.Wait()
}
}
func setGOOSForTest(tb testing.TB, goos string) {
tb.Helper()
envknob.Setenv("TS_DEBUG_FAKE_GOOS", goos)