Files
tailscale/derp/forward.go
Mike O'Driscoll 858b53b289 derp: batch mesh ForwardPacket writes (#22064)
derp.Client.ForwardPacket flushed on every call, so each packet a
derper forwards to a mesh peer cost its own TLS record and write
syscall, serialized behind the client's write mutex with the syscall
inside the critical section.

ForwardPacket now appends the frame to one of two 16 KiB buffers and
returns. A flusher goroutine, started if none is running, swaps the
buffer for the spare under the mutex and writes it with the mutex
released, so callers keep filling the spare while a write is in
progress and frames from concurrent callers share a write. A caller
that finds the spare full waits for the write to complete, preserving
backpressure. The buffers are allocated on first forward, so only mesh
clients carry them.

Forward buffers are written directly to the connection rather than
through the client's bufio.Writer. A new mutex serializes those writes
with the client's other senders, since not every Conn tolerates
concurrent writes and bufio can split one frame over several.

A failed write is logged with the number of frames dropped, closes the
connection, wakes waiting callers with the error, and is returned by
every later call, so derphttp reconnects as before.

Adds BenchmarkForwardPacket, which also reports percentiles of
ForwardPacket's own latency, unit tests for batching, backpressure,
oversized frames, write failure, and write serialization, and an
end-to-end test that concurrently forwarded packets all reach the
destination.

Updates tailscale/corp#50628

Signed-off-by: Mike O'Driscoll <mikeo@tailscale.com>
2026-11-02 15:57:10 -05:00

173 lines
6.7 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package derp
import (
"sync"
"sync/atomic"
"tailscale.com/types/key"
)
// forwardBufSize is how many bytes of forward packet frames a
// forwardBuffers buffer holds before callers must wait: the TLS
// maximum plaintext record size, so a full buffer goes out as exactly
// one record.
//
// A buffer may grow past it for a single frame that is bigger than
// that, and is then kept at its larger size rather than reallocated
// for every such frame. So a client that has forwarded frames near
// [MaxPacketSize] holds two buffers of up to about 80 KiB each, with
// append's growth, instead of two of 16 KiB. Only clients that forward
// at all, in practice DERP servers' mesh connections, allocate them.
const forwardBufSize = 16 << 10
// ForwardDroppedFrames and ForwardDroppedBytes count, process-wide,
// the forward packet frames and bytes that all Clients have dropped
// because a write failed: the frames of the failed write and those
// buffered behind it, whose [Client.ForwardPacket] calls had already
// returned. The owner reconnects, but the frames are gone, and a DERP
// server has no other way to count them, having been told they were
// forwarded. Both count a deliberate close by the owner too, if a
// write was in flight. They only ever grow.
var ForwardDroppedFrames, ForwardDroppedBytes atomic.Int64
// A forwardFlusher is what [forwardBuffers] writes to: the connection
// side of [Client.ForwardPacket], passed to each call rather than
// stored, so a zero forwardBuffers is ready to use.
type forwardFlusher interface {
// flush writes buf, which holds whole frames, to the connection.
// It must not retain buf.
flush(buf []byte) error
// flushFailed is called once, after flush returns err, with how
// many frames and bytes were dropped: those of the failed write
// and those buffered behind it. No flush follows.
flushFailed(err error, lostFrames, lostBytes int)
}
// forwardBuffers double-buffers forward packet frames on their way to
// a connection. [forwardBuffers.push] appends a frame to pending,
// and a flusher goroutine, which push starts if none is running,
// swaps pending for the buffer it just wrote and writes it, with mu
// released, until nothing is pending. Callers keep filling pending
// while a write is in progress and wait only when pending is full,
// which is how a slow connection applies backpressure. The zero value
// is ready to use.
//
// Once a write fails, err is set and every push returns it, so the
// owner can reconnect.
type forwardBuffers struct {
mu sync.Mutex
cond sync.Cond // on mu; broadcast when pending is swapped out or err is set
pending []byte // frames waiting to be written; nil until first use
frames int // number of frames in pending
idle []byte // the other buffer while no flusher runs; nil while one does
err error // sticky: the first write error
}
// push appends a forward packet frame to the pending buffer, starting
// a flusher goroutine if none is running. It waits while pending is
// full. It returns the sticky write error, if any, without buffering.
func (fb *forwardBuffers) push(w forwardFlusher, srcKey, dstKey key.NodePublic, pkt []byte) error {
buf, err := fb.add(srcKey, dstKey, pkt)
if err != nil {
return err
}
// Whether a flusher is running is encoded in the idle buffer: it is
// nil exactly while one runs, since the flusher owns it then. So add
// returns a buffer to start a flusher with only when none is running;
// otherwise the running one picks the frame up at its next swap.
if buf != nil {
go fb.run(w, buf)
}
return nil
}
// add is push's work under mu: it appends the frame to pending and,
// if no flusher is running, claims the idle buffer and returns it for
// the caller to start one with. If a flusher is running, idle is nil
// and so is the result, and the running flusher will take the frame.
func (fb *forwardBuffers) add(srcKey, dstKey key.NodePublic, pkt []byte) (idle []byte, err error) {
fb.mu.Lock()
defer fb.mu.Unlock()
if fb.pending == nil {
fb.cond.L = &fb.mu
fb.pending = make([]byte, 0, forwardBufSize)
fb.idle = make([]byte, 0, forwardBufSize)
}
// Wait for room. The flusher empties pending by swapping it out,
// which it does once the write of the other buffer completes.
// An empty buffer always has room, even for a frame bigger than
// forwardBufSize, which grows it. Fullness is measured against
// forwardBufSize rather than the buffer's capacity so that a
// grown buffer, which is kept, doesn't batch more than the rest.
bodyLen := KeyLen*2 + len(pkt)
for fb.err == nil && len(fb.pending) > 0 && len(fb.pending)+FrameHeaderLen+bodyLen > forwardBufSize {
fb.cond.Wait()
}
if fb.err != nil {
return nil, fb.err
}
b := appendFrameHeader(fb.pending, FrameForwardPacket, uint32(bodyLen))
b = srcKey.AppendTo(b)
b = dstKey.AppendTo(b)
fb.pending = append(b, pkt...)
fb.frames++
// Claim the idle buffer for a new flusher, if there is one to
// claim; a nil idle means a flusher is running and owns it.
idle, fb.idle = fb.idle, nil
return idle, nil
}
// run is the flusher goroutine. It owns buf, the buffer not being
// filled, and trades it for pending each time around until nothing
// is pending, when it parks buf as idle and exits.
func (fb *forwardBuffers) run(w forwardFlusher, buf []byte) {
for {
var frames int
if buf, frames = fb.next(buf); buf == nil {
return
}
if err := w.flush(buf); err != nil {
fb.fail(w, err, buf, frames)
return
}
}
}
// next swaps buf, which the flusher has finished with, for the
// pending buffer and returns it with its frame count. If nothing is
// pending it parks buf as idle and returns nil, and the flusher must
// exit; the next push starts another.
func (fb *forwardBuffers) next(buf []byte) (pending []byte, frames int) {
fb.mu.Lock()
defer fb.mu.Unlock()
if len(fb.pending) == 0 {
fb.idle = buf[:0]
return nil, 0
}
pending, frames = fb.pending, fb.frames
fb.pending, fb.frames = buf[:0], 0
fb.cond.Broadcast() // pending has room again
return pending, frames
}
// fail ends forwarding after writing buf, holding frames frames,
// failed with err. It makes err sticky, drops what's pending, wakes
// waiting callers so they return err, and reports the loss to the
// flusher. The flusher's buffer is not parked, so no flusher starts
// again.
func (fb *forwardBuffers) fail(w forwardFlusher, err error, buf []byte, frames int) {
fb.mu.Lock()
fb.err = err
lostFrames, lostBytes := frames+fb.frames, len(buf)+len(fb.pending)
fb.pending, fb.frames = fb.pending[:0], 0
fb.cond.Broadcast()
fb.mu.Unlock()
ForwardDroppedFrames.Add(int64(lostFrames))
ForwardDroppedBytes.Add(int64(lostBytes))
w.flushFailed(err, lostFrames, lostBytes)
}