Tailscale — Patterns#

Concurrency patterns#

Goroutine run-loops with select#

  • Usage: The dominant concurrency idiom in Tailscale. 324 go func launches pair with 355 select {} 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.Context across the codebase. The context tree rooted at cmd/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.WithTimeout used pervasively — e.g., sessionrecording/connect.go:60 creates 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.WaitGroup teardown 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) (or make(chan error, N)) is created before the goroutines launch; results are collected after.
  • Example: sessionrecording/connect.go:234errChan := 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 errgroup for single-result cases and is preferred throughout the codebase.

errgroup (limited use)#

  • Usage: golang.org/x/sync/errgroup appears 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:602g := 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 via close(ch)). The syncs.ClosedChan() helper returns a pre-closed channel for the common “already done” case.
  • Example: ssh/tailssh/tailssh.go:998ss.exitHandled = make(chan struct{}) closed when an SSH session exit is processed.
  • Assessment: Standard Go idiom, well applied. The syncs.ClosedChan() helper avoids repeated make+close pairs for the sentinel case.

Semaphore via buffered channel#

  • Usage: syncs.Semaphore in syncs/syncs.go:192 implements 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] in syncs/shardedmap.go uses multiple map shards separated by cpu.CacheLinePad to reduce lock contention. Used in paths where many goroutines perform concurrent map lookups.
  • Example: syncs/shardedmap.go:21 — each mapShard has its own sync.Mutex and a cpu.CacheLinePad field to prevent false sharing of neighboring shards’ mutexes.
  • Assessment: Sophisticated. The cpu.CacheLinePad detail 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 %w with extensive errors.Is/errors.As (297 occurrences). No third-party pkg/errors dependency. Custom error types defined sparingly for cases where callers need to inspect error details programmatically.
  • Error types defined:
    • client/local/local.go:186AccessDeniedError, PreconditionsFailedError, httpStatusError for the local API client
    • sessionrecording/connect.go:148EventAPINotSupportedErr (struct with Error() method; callee checks with errors.As)
    • ssh/tailssh/tailssh.go:1577userVisibleError (wraps an error to signal it should be shown to the SSH user)
    • client/tailscale/acl.go:178ACLTestError (structured error from ACL test evaluation)
  • Wrapping approach: fmt.Errorf("...context: %w", err) for chain preservation; errors.New for leaf errors. Consistent use of %w makes unwrapping reliable. Some older code uses %v for non-wrapped formatting.
  • Examples:
    • sessionrecording/connect.go:86fmt.Errorf("recording: error starting recording on %q: %w", ap, err) — wraps with context
    • sessionrecording/connect.go:302if !errors.Is(err, io.EOF) { ... } — correct sentinel comparison

Configuration pattern#

  • Approach: Tailscale uses three distinct config mechanisms layered by lifecycle:
    1. Compile-time feature flagsfeature/buildfeatures generates boolean constants (HasSSH, HasNetstack) via _enabled.go/_disabled.go file pairs selected by build tags.
    2. envknob package — environment variable knobs registered as package-level var via envknob.RegisterBool("TS_DEBUG_SSH_VLOG") etc. Enforces that env reads do not happen in init() (lazy evaluation via sync.Once internally).
    3. flag package (stdlib) — daemon flags passed at process start: --tun, --state, --socket, --config, --port, --verbose.
  • Example: ssh/tailssh/tailssh.go:52-55 — four envknob.RegisterBool calls at package level, evaluated lazily on first use, allowing test code to override them via envknob.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.System is a struct with typed SubSystem[T] slot fields (generic set-once slots). Created in main(), populated during getLocalBackend(), and passed to ipnlocal.NewLocalBackend() which reaches into it for dependencies. The 2023 redesign comment in the tsd package 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.System struct, constructor, and all instantiation sites.

Other notable patterns#

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 panics
  • MutexValue[T any] — a mutex-protected value with documented guidance on when to prefer it over AtomicValue or atomic.Pointer
  • Map[K comparable, V any] — type-safe RWMutex-protected map
  • ShardedMap[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 require interface{} otherwise)
  • syncs.AtomicValue[T], syncs.MutexValue[T], syncs.Map[K,V], syncs.ShardedMap[K,V] — concurrency utilities
  • tsd.SubSystem[T] — DI container slots
  • release/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.