Tailscale — Patterns#
Concurrency patterns#
Goroutine run-loops with select#
- Usage: The dominant concurrency idiom in Tailscale. 324
go funclaunches pair with 355select {}blocks — nearly a 1:1 ratio, indicating that the majority of goroutines are long-running event loops rather than fire-and-forget tasks. - Example:
wgengine/magicsock/magicsock.go— MagicSock’s receive loop and path-probe loop each spawn a goroutine that selects over channel signals, timers, and context cancellation indefinitely. - Assessment: Highly idiomatic and appropriate. Each goroutine owns a distinct concern and communicates only via channels or explicit callbacks. Avoids shared-state races in the hot network path.
Context cancellation as the primary shutdown signal#
- Usage: 1,301 uses of
context.Contextacross the codebase. The context tree rooted atcmd/tailscaled/tailscaled.go:run()is the primary termination mechanism for all subsystems. Every blocking call, dial, and HTTP request threads the context through. - Example:
context.WithCancel/context.WithTimeoutused pervasively — e.g.,sessionrecording/connect.go:60creates per-dial timeouts from the parent context. - Assessment: Exemplary. Context is properly threaded rather than stored in structs. The daemon gracefully terminates all subsystems by cancelling the root context; no
sync.WaitGroupteardown ceremony is needed in most paths.
Buffered single-error channels (fan-out result collection)#
- Usage: Pattern used when spawning N goroutines that each produce at most one error. A
make(chan error, 1)(ormake(chan error, N)) is created before the goroutines launch; results are collected after. - Example:
sessionrecording/connect.go:234—errChan := make(chan error, 1)with a paired goroutine that sends one error or nil. - Assessment: Idiomatic. Buffering prevents goroutine leaks when the receiver abandons the channel. The pattern is simpler than
errgroupfor single-result cases and is preferred throughout the codebase.
errgroup (limited use)#
- Usage:
golang.org/x/sync/errgroupappears in only four non-test files:prober/prober.go,derp/derpserver/derpserver.go,cmd/k8s-proxy/k8s-proxy.go, and a test. This is notably sparse for a codebase of this size. - Example:
prober/prober.go:602—g := new(errgroup.Group)to parallelize probe checks. - Assessment: Tailscale favors manual goroutine + channel patterns over errgroup for the core daemon. errgroup appears only in newer peripheral binaries. This reflects a preference for explicit control over lifecycle.
Signalling channels (done/notify)#
- Usage:
make(chan struct{})used extensively as lightweight signals (zero-allocation broadcast viaclose(ch)). Thesyncs.ClosedChan()helper returns a pre-closed channel for the common “already done” case. - Example:
ssh/tailssh/tailssh.go:998—ss.exitHandled = make(chan struct{})closed when an SSH session exit is processed. - Assessment: Standard Go idiom, well applied. The
syncs.ClosedChan()helper avoids repeatedmake+closepairs for the sentinel case.
Semaphore via buffered channel#
- Usage:
syncs.Semaphoreinsyncs/syncs.go:192implements a counting semaphore on top of a buffered channel with an internal hit counter. - Example: Used to cap concurrency for operations that must not saturate resources (e.g., K8s operator auth key provisioning).
- Assessment: Idiomatic. Tailscale codified the pattern into a reusable type rather than leaving ad-hoc
make(chan struct{}, N)patterns scattered.
Sharded maps for high-concurrency hot paths#
- Usage:
syncs.ShardedMap[K, V]insyncs/shardedmap.gouses multiple map shards separated bycpu.CacheLinePadto reduce lock contention. Used in paths where many goroutines perform concurrent map lookups. - Example:
syncs/shardedmap.go:21— eachmapShardhas its ownsync.Mutexand acpu.CacheLinePadfield to prevent false sharing of neighboring shards’ mutexes. - Assessment: Sophisticated. The
cpu.CacheLinePaddetail shows awareness of CPU cache topology — rare in typical Go code, appropriate for a high-performance networking daemon.
Error handling#
- Style: Mixed
errors.New+fmt.Errorf %wwith extensiveerrors.Is/errors.As(297 occurrences). No third-partypkg/errorsdependency. Custom error types defined sparingly for cases where callers need to inspect error details programmatically. - Error types defined:
client/local/local.go:186—AccessDeniedError,PreconditionsFailedError,httpStatusErrorfor the local API clientsessionrecording/connect.go:148—EventAPINotSupportedErr(struct withError()method; callee checks witherrors.As)ssh/tailssh/tailssh.go:1577—userVisibleError(wraps an error to signal it should be shown to the SSH user)client/tailscale/acl.go:178—ACLTestError(structured error from ACL test evaluation)
- Wrapping approach:
fmt.Errorf("...context: %w", err)for chain preservation;errors.Newfor leaf errors. Consistent use of%wmakes unwrapping reliable. Some older code uses%vfor non-wrapped formatting. - Examples:
sessionrecording/connect.go:86—fmt.Errorf("recording: error starting recording on %q: %w", ap, err)— wraps with contextsessionrecording/connect.go:302—if !errors.Is(err, io.EOF) { ... }— correct sentinel comparison
Configuration pattern#
- Approach: Tailscale uses three distinct config mechanisms layered by lifecycle:
- Compile-time feature flags —
feature/buildfeaturesgenerates boolean constants (HasSSH,HasNetstack) via_enabled.go/_disabled.gofile pairs selected by build tags. envknobpackage — environment variable knobs registered as package-levelvarviaenvknob.RegisterBool("TS_DEBUG_SSH_VLOG")etc. Enforces that env reads do not happen ininit()(lazy evaluation via sync.Once internally).flagpackage (stdlib) — daemon flags passed at process start:--tun,--state,--socket,--config,--port,--verbose.
- Compile-time feature flags —
- Example:
ssh/tailssh/tailssh.go:52-55— fourenvknob.RegisterBoolcalls at package level, evaluated lazily on first use, allowing test code to override them viaenvknob.Setenv. - Assessment: The separation between compile-time, link-time, environment, and flag configuration is deliberate and clean. Feature availability is encoded statically in the binary, not checked at runtime from a config file.
Dependency injection#
- Approach: Manual wiring via
tsd.System— an explicit service-locator/DI container. No codegen framework (no Wire, Dig, or Fx). - Evidence:
tsd.Systemis a struct with typedSubSystem[T]slot fields (generic set-once slots). Created inmain(), populated duringgetLocalBackend(), and passed toipnlocal.NewLocalBackend()which reaches into it for dependencies. The 2023 redesign comment in thetsdpackage explains this was motivated by needing to wire the same subsystems across five different host environments without global variables or import cycles. - Assessment: Pragmatic. The explicit container is more auditable than a reflection-based DI framework and avoids the “magic” that frustrates debugging. The downside is verbosity — adding a new subsystem requires editing
tsd.Systemstruct, constructor, and all instantiation sites.
Other notable patterns#
feature.Hook[Func] — generic typed link-time hook#
The most architecturally distinctive pattern in the codebase. feature.Hook[Func] (defined in feature/feature.go) is a generic struct holding a function value that can be set exactly once (panics on double-set). Optional subsystems register themselves at init() time via blank imports:
// In ssh/tailssh/tailssh.go init():
ipnlocal.RegisterNewSSHServer(func(logf logger.Logf, lb *ipnlocal.LocalBackend) (ipnlocal.SSHServer, error) { ... })
feature.HookGetSSHHostKeyPublicStrings.Set(getHostKeyPublicStrings)Core code gates on hook.GetOk():
if newSSHServer, ok := newSSHServer.GetOk(); ok {
srv, err = newSSHServer(logf, b)
}This achieves dead-code elimination for lean builds (e.g., container image without SSH) without //go:build guards scattered at every call site. The generic [Func] type parameter makes the hook type-safe — the compiler enforces the callback signature.
logger.Logf as function-type dependency#
Rather than injecting a logger interface, Tailscale injects logging as a bare function value:
type Logf func(format string, args ...any)Constructors accept logf logger.Logf as a first or second argument across hundreds of packages:
func NewUserspaceEngine(logf logger.Logf, conf Config) (Engine, error)
func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, ...) (*LocalBackend, error)Assessment: A classic Go idiom — functions are first-class, and a Logf func is easier to wrap, silence, or redirect than an interface. The logging dependency is explicit in every constructor signature, which aids testability (pass t.Logf in tests).
Generic concurrency primitives in syncs package#
syncs/syncs.go defines a family of generic types that go beyond what sync and sync/atomic provide:
AtomicValue[T any]— generic atomic.Value with proper wrapping to avoid interface-type panicsMutexValue[T any]— a mutex-protected value with documented guidance on when to prefer it over AtomicValue or atomic.PointerMap[K comparable, V any]— type-safe RWMutex-protected mapShardedMap[K comparable, V any]— lock-sharded map with cache-line padding
The in-source documentation explicitly guides users on the trade-offs between these types (AtomicValue vs MutexValue vs atomic.Pointer). This is a rare example of in-codebase API guidance replacing what would otherwise be a wiki page.
Builder pattern (StatusBuilder, UpdateBuilder)#
ipn/ipnstate/ipnstate.go:361 defines StatusBuilder — a mutation-accumulator that multiple subsystems write into concurrently before producing a final immutable *Status. The builder acquires its own mutex for thread safety. tka/builder.go:26 defines UpdateBuilder for constructing TKA (Tailscale Key Authority) update chains.
Assessment: Well-suited to the status-assembly use case where multiple packages contribute partial data. The builder centralizes the mutex and avoids the callers needing to synchronize with each other.
Logf wrapping / decoration#
The logger.Logf function type enables a “decorator” pattern: logger.WithPrefix(logf, "[prefix] ") returns a new Logf that prepends context to every message. This is used throughout to give subsystem-specific log lines without changing callsites.
Interface consumer-side definitions (ISP applied)#
Consumer packages define their own narrow interface for the subset of LocalBackend they need. For example, ssh/tailssh/tailssh.go:76 defines:
type ipnLocalBackend interface {
GetSSHHostKeys() []gossh.Signer
CheckIPForwarding() error
// ~10 methods total
}This is Go’s interface segregation principle in practice: the SSH package doesn’t import ipnlocal directly, reducing coupling and enabling simpler fakes in tests.
Generics in Go 1.21+#
Generics adoption is targeted and principled — not retrofitted everywhere. Key uses:
feature.Hook[Func]— type-safe function slots (would requireinterface{}otherwise)syncs.AtomicValue[T],syncs.MutexValue[T],syncs.Map[K,V],syncs.ShardedMap[K,V]— concurrency utilitiestsd.SubSystem[T]— DI container slotsrelease/dist.Memoize[O],MemoizedFn[T]— memoization utilities- Helper functions in tests and utilities:
first[T],filterSlice[T],decodeJSON[T]
Generic types are concentrated in infrastructure packages (syncs, tsd, feature), not in domain logic. This is the pragmatic sweet spot: use generics to eliminate type assertions in foundational utilities, not to add abstraction to business logic.