wireguard-go — Patterns#

Concurrency patterns#

Parallel worker pool with named goroutines#

  • Usage: Core data-path pipeline — RoutineEncryption × N, RoutineDecryption × N, RoutineHandshake × N where N = runtime.NumCPU(); also RoutineReceiveIncoming (one per address family from the bind)
  • Example: device/device.go:315go device.RoutineEncryption(i + 1) launched in a loop; same pattern for decryption and handshake
  • Assessment: Highly effective. The named-method approach (not anonymous goroutines) makes goroutine profiles readable — each stack frame identifies itself as RoutineEncryption, RoutineHandshake, etc. The worker count is pinned to CPU count, preventing over-subscription.

Pipeline with per-element mutex ordering#

  • Usage: Outbound and inbound packet pipelines — the central novel pattern of this codebase
  • Example: device/send.go:47, device/receive.go:28QueueOutboundElement and QueueInboundElement both embed sync.Mutex; encryption/decryption goroutines lock the element’s mutex before processing and unlock on completion; the sequential per-peer goroutine (RoutineSequentialSender, RoutineSequentialReceiver) blocks on each element’s mutex to enforce FIFO ordering
  • Assessment: Ingenious. This achieves parallel encryption while maintaining per-peer packet ordering without a coordinator goroutine or a second channel. The queue is the ordering mechanism: workers encrypt in parallel and race to finish; the sequential consumer enforces order by walking the queue in arrival order and blocking on each element’s mutex. Zero extra coordination overhead.

WaitGroup-ref-counted channel close (queue lifecycle)#

  • Usage: outboundQueue, inboundQueue, handshakeQueue in device/channels.go
  • Example: device/channels.go:26-36 — queue is created with wg.Add(1); a background goroutine waits on wg.Wait() then close(q.c); every goroutine that writes to the queue calls wg.Add(1) on creation and wg.Done() on shutdown; the initial reference is removed last
  • Assessment: Elegant lifetime management. The queue’s channel is closed exactly when the last writer exits, which causes any range-over-channel consumers to terminate naturally. This avoids a separate close signal and the risk of closing a channel with remaining writers. 36 goroutines, 39 select statements — this pattern is used pervasively.

Autodraining queues via runtime finalizers#

  • Usage: Per-peer autodrainingInboundQueue and autodrainingOutboundQueue in device/channels.go:74-137
  • Example: device/channels.go:86runtime.SetFinalizer(q, device.flushInboundQueue) registers a drain function that returns all buffered elements to pools when the queue is garbage-collected
  • Assessment: Unusual but justified. Peer lifetimes are complex: a peer can be removed while packets destined for it are in-flight in various queues. Rather than tracking every reference, the autodraining queue ensures that orphaned buffers are returned to the pool when the peer struct is GC’d — preventing pool exhaustion. The docs note the channel must never be closed; shutdown uses a sentinel nil instead.

Closed-channel broadcast for shutdown signaling#

  • Usage: device.closed — a chan struct{} closed when the device shuts down; device.Wait() returns it
  • Example: device/device.go:287,399device.closed = make(chan struct{}) at init; close(device.closed) at shutdown; callers use <-device.Wait() or select on it
  • Assessment: Idiomatic Go. Closing a channel is the standard broadcast-to-N-goroutines primitive; all goroutines blocked on <-device.closed unblock simultaneously without races.

State machine with atomic state + mutex for transitions#

  • Usage: device.state in device/device.go:22-33
  • Example: device/device.go:101-139deviceState is a uint32-backed type with iota constants (deviceStateDown, deviceStateUp, deviceStateClosed); state is read via atomic.Uint32.Load() (no lock needed for reads); transitions use device.state.Lock() to serialize writers
  • Assessment: Classic atomic-for-reads, mutex-for-writes pattern. A //go:generate stringer annotation generates a human-readable String() method for the state type — useful for logging.

Graceful shutdown with ordered WaitGroup drain#

  • Usage: device.Close() in device/device.go:380-400
  • Example: Sequence: close TUN → close bind → stop peer goroutines (peer.stopping.Wait()) → remove initial WaitGroup reference from each queue → drain queue WaitGroups → close rate limiter → close(device.closed)
  • Assessment: Careful and correct. The ordering matters: TUN close stops the inbound producer; bind close stops incoming UDP; then queues drain in dependency order. Each stage waits for its goroutines to exit before the next stage begins.

Error handling#

  • Style: Mostly sentinel errors and fmt.Errorf wrapping with %w; no pkg/errors; sparse use of custom error types
  • Error types defined:
    • conn/conn.go:88-89ErrBindAlreadyOpen, ErrWrongEndpointType (package-level sentinel vars)
    • tun/errors.go:11ErrTooManySegments (sentinel)
    • device/noise-helpers.go:98errInvalidPublicKey (unexported sentinel)
    • device/noise-protocol.go:119errMessageLengthMismatch (unexported sentinel)
  • Wrapping approach: fmt.Errorf("...: %w", err) for wrapping with context; errors.New("...") for leaf errors; errors.Is / errors.As for matching (e.g., conn/bind_std.go:160errors.Is(err, syscall.EADDRINUSE))
  • Examples:
    • conn/gso_linux.go:33fmt.Errorf("error parsing socket control message: %w", err) — wrapping with context
    • tun/tun_windows.go:71fmt.Errorf("Error creating interface: %w", err) — Windows-specific wrapping
    • conn/errors_linux.go:17errors.As(err, &serr) — unwrapping to syscall.Errno for platform error inspection
  • Assessment: Conservative and correct. The protocol engine itself rarely returns errors to callers — errors are logged and the goroutine either retries or shuts down. Public API errors use sentinel vars for programmatic matching; internal protocol errors are strings. No excessive wrapping chains.

Configuration pattern#

  • Approach: Constructor-parameter injection + runtime key-value protocol (UAPI); no functional options, no builder, no config struct
  • Example: device.NewDevice(tunDevice tun.Device, bind conn.Bind, logger *Logger) — three concrete dependencies injected at construction; runtime configuration arrives as text key=value pairs via IpcSetOperation / IpcGetOperation
  • The UAPI protocol itself has a simple grammar: each set command is a series of key=value\n lines terminated by \n\n. The parser in device/uapi.go is a hand-written loop over a bufio.Scanner — no struct tags, no reflection, no YAML.
  • Assessment: Deliberately minimal. The UAPI format matches the kernel WireGuard wg(8) tool interface, ensuring wg set/wg show commands work identically against userspace and kernel implementations. Simplicity is a correctness argument.

Dependency injection#

  • Approach: Manual constructor injection — no wire, dig, or fx
  • Evidence: device/device.goNewDevice(tun tun.Device, bind conn.Bind, logger *Logger) passes the two abstraction interfaces directly. main.go creates concrete implementations (tun.CreateTUN, conn.NewDefaultBind) and passes them in. Tailscale and wireguard-windows substitute their own implementations at the call site.
  • Assessment: Appropriate for this scope. The entire dependency graph has two seams (tun.Device, conn.Bind) and one logger. A DI framework would add complexity with zero benefit. The simplicity is itself a feature: embedding applications can read NewDevice’s signature and understand what they need to provide.

Other notable patterns#

WaitPool: bounded sync.Pool with backpressure#

  • Location: device/pools.go:12-50
  • Description: WaitPool wraps sync.Pool with a maximum-count semaphore using sync.Cond. Get() blocks when count >= max; Put() signals the cond to unblock waiters. This converts the pool from “unbounded allocation on miss” to “bounded with backpressure” — preventing unbounded memory growth under load.
  • Assessment: Novel and important. Standard sync.Pool allocates a new object whenever it misses; under packet-storm conditions, this could cause unbounded heap growth. WaitPool caps the number of in-flight packet buffers at PreallocatedBuffersPerPool, providing implicit congestion control.

Build-tag polymorphism for platform variants#

  • Location: conn/, tun/, ipc/ — pervasive
  • Description: Every platform-specific behavior is expressed as a separate file gated by //go:build constraints. Examples: conn/gso_linux.go vs conn/gso_default.go, conn/sticky_linux.go vs conn/sticky_default.go, ipc/uapi_unix.go vs ipc/uapi_windows.go. Each variant satisfies the same function or interface signature.
  • Assessment: The canonical Go approach to OS portability. Avoids runtime.GOOS switches inside functions (which defeat dead-code elimination). The _default.go files act as no-op stubs, keeping the platform-specific optimizations invisible to the protocol engine.

//go:linkname to access internal runtime PRNG#

  • Location: device/timers.go:13-16
  • Description: //go:linkname fastrandn runtime.fastrandn — directly links to the Go runtime’s fast random number function (no lock, no global state, faster than math/rand). Used to add jitter to handshake retransmission timers.
  • Assessment: Aggressive and fragile — runtime.fastrandn is not a public API and could be renamed or removed. The comment in the source acknowledges this is “based heavily on timers.c from the kernel implementation.” The justification is that timer jitter requires a non-blocking PRNG on the hot path; at the time this code was written, there was no stdlib equivalent. It’s the kind of shortcut that is acceptable in a security-critical daemon maintained by the same team as the kernel implementation.

runtime.SetFinalizer for resource cleanup#

  • Location: device/channels.go:86, device/channels.go:118
  • Description: Used exclusively for autodraining packet queues when a peer is GC’d. Not used for file descriptors or sockets — those are closed explicitly.
  • Assessment: Narrow, defensively used. The Go spec discourages relying on finalizers for correctness, but here it is a safety net: buffers are returned to pools even if the caller forgets to drain. The code explicitly documents that senders must use sentinel nil to signal shutdown (not channel close), acknowledging the finalizer is not the primary mechanism.

Struct embedding for interface satisfaction#

  • Location: device/timers.go:24Timer embeds *time.Timer; various test fakes embed the interfaces they implement
  • Description: Timer embeds *time.Timer to inherit its Reset/Stop/C field, then adds modifyingLock and runningLock to make timer modification race-free. Test types (DummyBind, DummyEndpoint, fakeBindSized, fakeTUNDeviceSized) embed the production interfaces to inherit panicking stubs for methods they don’t need to override.
  • Assessment: Correct use of embedding for extension. The mutex wrapping in Timer is subtle: two locks are needed because time.Timer.Reset has a race condition when called concurrently with the timer firing — the modifyingLock serializes Reset/Stop calls; the runningLock ensures the expiration callback runs to completion before modification.

Type assertions as opt-in capability detection#

  • Location: device/sticky_linux.go:31, device/sticky_linux.go:118
  • Description: if _, ok := bind.(*conn.StdNetBind); !ok { return } — the Linux sticky-source-IP optimization is only applied if the bind is the standard one (not a custom embedding). Later, peer.endpoint.val.(*conn.StdNetEndpoint) extracts the concrete endpoint to read the source interface index.
  • Assessment: The right pattern for optional capabilities. The alternative — adding methods to conn.Bind — would force all implementations (including Tailscale’s) to implement Linux-specific socket APIs. Type assertion keeps the optimization local to the platform file.

Minimal context.Context usage (10 occurrences)#

  • Usage: Almost entirely in tun/netstack (gVisor integration) and test utilities; the protocol engine itself uses none
  • Assessment: Intentional. The device is a long-running daemon where cancellation is expressed through channel close (device.closed), not context propagation. Using context would add allocations on every packet-processing call. The netstack integration uses it because gVisor’s APIs require it.

No generics#

  • Assessment: The codebase predates Go 1.18 generics and has not adopted them. The few places where a generic Pool[T] would eliminate type assertions (pools.go) have not been refactored — likely because the type-assertion overhead at pool boundaries is negligible compared to the ChaCha20 work happening downstream. Consistent with the project’s philosophy of no unnecessary complexity.