Files
tailscale/ipn/ipnlocal/shutdown_test.go
Brad Fitzpatrick 4c4d1c35f8 ipn/ipnlocal: avoid deadlocks during shutdown
LocalBackend.Shutdown waits for the ACME refresh loop and active SSH
sessions. Both can be blocked acquiring LocalBackend.mu, so waiting while
holding that mutex deadlocks shutdown.

Detach the SSH server under the mutex, then stop both subsystems after
releasing it. Prevent their work from restarting once shutdown begins, and
serialize repeated Shutdown calls with sync.Once.

Add regression tests that verify subsystem shutdown runs without
LocalBackend.mu held.

Updates tailscale/corp#45964

Change-Id: I37ead4f26fbfb5703a83882668d98a8862ba7d67
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-08-01 13:57:38 -07:00

85 lines
2.0 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"errors"
"net"
"sync/atomic"
"testing"
"tailscale.com/ipn"
)
type shutdownTestSSHServer struct {
b *LocalBackend
calls atomic.Int32
t *testing.T
}
func (s *shutdownTestSSHServer) HandleSSHConn(net.Conn) error { return nil }
func (s *shutdownTestSSHServer) NumActiveConns() int { return 0 }
func (s *shutdownTestSSHServer) OnPolicyChange() {}
func (s *shutdownTestSSHServer) Shutdown() {
s.calls.Add(1)
if !s.b.mu.TryLock() {
s.t.Error("LocalBackend.mu held while shutting down SSH server")
return
}
s.b.mu.Unlock()
s.b.NodeKey() // do something that requires the lock
}
func TestShutdownReleasesMutexBeforeWaitingForSubsystems(t *testing.T) {
b := newTestLocalBackend(t)
ssh := &shutdownTestSSHServer{b: b, t: t}
b.mu.Lock()
b.sshServer = ssh
b.mu.Unlock()
var certShutdownCalls atomic.Int32
restore := HookShutdownCertRefreshLoop.SetForTest(func(b *LocalBackend) {
certShutdownCalls.Add(1)
if !b.mu.TryLock() {
t.Error("LocalBackend.mu held while shutting down cert refresh loop")
return
}
b.mu.Unlock()
b.ServeConfig()
})
t.Cleanup(restore)
b.Shutdown()
b.Shutdown()
if got := certShutdownCalls.Load(); got != 1 {
t.Errorf("cert refresh shutdown called %d times; want 1", got)
}
if got := ssh.calls.Load(); got != 1 {
t.Errorf("SSH shutdown called %d times; want 1", got)
}
if _, err := b.sshServerOrInit(); !errors.Is(err, errShutdown) {
t.Errorf("sshServerOrInit after Shutdown = %v; want %v", err, errShutdown)
}
}
func TestCertRefreshLoopDoesNotRestartAfterShutdown(t *testing.T) {
b := newTestLocalBackend(t)
var updates atomic.Int32
restore := HookUpdateCertRefreshLoop.SetForTest(func(*LocalBackend, ipn.State, ipn.ServeConfigView) {
updates.Add(1)
})
t.Cleanup(restore)
b.Shutdown()
b.mu.Lock()
b.updateCertRefreshLoopLocked()
b.mu.Unlock()
if got := updates.Load(); got != 0 {
t.Errorf("cert refresh loop restarted %d times after Shutdown; want 0", got)
}
}