frp — Patterns#
Concurrency patterns#
Goroutine-per-component lifecycle#
- Usage: 45
go funcoccurrences; every major subsystem is launched as a named goroutine inRun()methods - Example:
server/service.go—go svr.HandleListener(listener),go webServer.Run(),go quicListener.Run(), etc. - Assessment: Clean and idiomatic. Each subsystem goroutine receives a
context.Contextand exits when it is cancelled. No fire-and-forget goroutines observed; every one has a corresponding shutdown path.
Channel-based signaling and queuing#
- Usage: 10+
make(chan …)sites; channels serve two distinct roles: (1) buffered queues for UDP packet forwarding (capacity 1024), (2)struct{}close channels as one-shot signals - Example (queue):
client/proxy/udp.go:102—pxy.readCh = make(chan *msg.UDPPacket, 1024)/pxy.sendCh = make(chan msg.Message, 1024) - Example (signal):
client/proxy/proxy_wrapper.go:112—closeCh: make(chan struct{})used to unblock waiting goroutines on shutdown - Assessment: Buffered channels for UDP avoid head-of-line blocking between receive and send loops. Signal channels are closed (not sent-to) so all listeners unblock simultaneously — correct fan-out pattern.
Select-based multiplexing#
- Usage: 55
selectblocks — the core idiom for coordinating multiple event sources - Example:
client/control.go— select over incoming messages, heartbeat ticks, and context done;client/proxy/proxy_wrapper.go— select over start messages, close channel, and health notifications - Assessment: Idiomatic.
selectis never used as a polling mechanism; every case has a clear semantic owner.
errgroup for concurrent fan-out#
- Usage: 1 explicit use (
pkg/nathole/controller.go:233) - Example: NAT hole-punching coordination — multiple STUN probes are launched in an
errgroup.Group; the first error cancels the rest - Assessment: Appropriate and minimal. The project does not over-use errgroup; it reserves it for places where concurrent errors genuinely need aggregation.
Context cancellation / graceful shutdown#
- Usage: 114
context.Contextoccurrences; every long-running goroutine receives a context;context.WithCancel/context.WithCancelCauseused at service boundaries - Example:
client/service.go:225—ctx, cancel := context.WithCancelCause(ctx); cancellation reason is stored and surfaced viaStatusExporterfor reconnect logic - Assessment: Exemplary context discipline. The root context propagates all the way to leaf goroutines (health checkers, work-connection loops, visitor retry loops).
Rate limiting via token-bucket wrappers#
- Usage:
golang.org/x/time/rate.Limiterwrapped inio.Reader(pkg/util/limit/reader.go) andio.Writer(pkg/util/limit/writer.go) - Example:
client/proxy/proxy.go:65-68— ifBandwidthLimitis configured, arate.Limiteris injected intoBaseProxyand the work connection is wrapped at I/O time - Assessment: Elegant composition. Rate limiting is applied transparently at the
io.Reader/io.Writerlevel without touching proxy logic. Thelimit.Readerandlimit.Writercalllimiter.WaitN(ctx, n)after each read/write.
sync.Once for one-time initialization and safe close#
- Usage: 8 occurrences across production code
- Example:
client/connector.go:51—closeOnce sync.Onceensuresconnector.Close()is idempotent even when called concurrently;server/metrics/metrics.go:20—registerMetrics sync.Onceensures Prometheus metric registration happens exactly once regardless of test parallel runs - Assessment: Correct and targeted. Used only where “exactly once” semantics are genuinely needed.
Atomic values for lock-free hot state#
- Usage:
sync/atomicin 3 production files; total 80 sync-primitive references - Example:
client/proxy/proxy_wrapper.go:203—atomic.LoadUint32(&pw.health)/atomic.StoreUint32(&pw.health, 1|0)for health state toggled by the health-check goroutine and read by the proxy start loop;client/control.go:72—lastPong atomic.Valuefor heartbeat timestamp - Assessment: Conservative and correct use. Health status and heartbeat time are read/written by separate goroutines without requiring a mutex;
atomic.Valuestorestime.Timesafely.
Error handling#
- Style: Mixed: sentinel package-level
var Err* = errors.New(…)combined withfmt.Errorf("%w: …", sentinel)wrapping, and occasional plainfmt.Errorfwithout%w - Error types defined:
client/health/health.go—ErrHealthCheckTypeclient/event/event.go—ErrPayloadTypeclient/visitor/xtcp.go—ErrNoTunnelSessionclient/configmgmt/types.go—ErrInvalidArgument,ErrNotFound,ErrConflict,ErrStoreDisabled,ErrApplyConfigpkg/util/http/error.go—Errorstruct (HTTP status + message)- Wire-protocol errors are carried as plain strings inside
LoginResp.Error,NewProxyResp.Error, etc.
- Wrapping approach:
fmt.Errorf("%w: details", sentinel)is the preferred pattern inclient/config_manager.go; allows callers to useerrors.Is(err, configmgmt.ErrConflict)for type-safe discrimination.errors.Asanderrors.Isare both used at call sites. - Examples:
client/config_manager.go:28—fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)client/proxy/proxy.go:143—fmt.Errorf("create encryption stream error: %w", err)client/config_manager_test.go:52—errors.Is(err, configmgmt.ErrConflict)for assertion
Notable observation: Wire-protocol errors (e.g., proxy registration failure) are serialized as plain strings in JSON messages and re-surfaced as Go errors on the receiving side via errors.New(resp.Error). This works across process boundaries but loses type information.
Configuration pattern#
- Approach: Typed config structs (
pkg/config/v1) loaded from YAML/TOML/JSON. Cobra CLI flags are registered against the same structs viaconfig.RegisterServerConfigFlags. For the service initialization API, aServiceOptionsstruct acts as a “named parameter bag” — the Go idiomatic alternative to functional options when there are many fields. - Example (
ServiceOptionsbag):// client/service.go type ServiceOptions struct { Common *v1.ClientCommonConfig ProxyCfgs []v1.ProxyConfigurer VisitorCfgs []v1.VisitorConfigurer ConfigFilePath string ConnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector // injection point ... } - Functional options: Used in test infrastructure (
test/e2e/mock/server/httpserver) and for config-flag registration (pkg/config/flags.go—WithSSHMode() RegisterFlagOption). Not used for production service construction. - Hot reload:
source.Aggregatorwatches a file-backedConfigSourceand callsproxy.Manager.UpdateAll/visitor.Manager.UpdateAllto diff and apply proxy changes without reconnecting.
Dependency injection#
- Approach: Manual constructor wiring — no DI framework (no wire, dig, or fx)
- Evidence:
server.NewService(*v1.ServerConfig)creates all sub-managers inline and stores them as struct fieldsResourceControlleracts as a “dependency bundle” struct passed into everyserver.Controlat construction time, avoiding an ever-growing constructor argument listSessionContextis a “context object” pattern: a struct bundling authenticated session metadata (runID, login info, resource controller) that is threaded into per-session componentsServiceOptions.ConnectorCreatoris a first-class function field that lets callers inject a customConnectorfactory — used bypkg/virtualto create in-processVirtualClientwith pipe-backed connections
Other notable patterns#
Registry via init() self-registration#
- How it works: Each proxy type (TCP, UDP, STCP, SUDP, XTCP, HTTP, HTTPS) registers its factory in its own
init()function:The registry is a// client/proxy/general_tcp.go func init() { RegisterProxyFactory(reflect.TypeOf(&v1.TCPProxyConfig{}), newGeneralTCPProxy) }map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy.NewProxylooks up the factory by the runtime type of the config struct and calls it. The same self-registration pattern appears in plugin registration (pkg/plugin/client/plugin.go—creators map[string]CreatorFn). - Assessment: This avoids a central switch statement for proxy dispatch, making it easy to add new proxy types without modifying
NewProxy. The use ofreflect.TypeOfas a map key is mildly unusual but correct since config types are stable value types. A potential downside: static analysis tools can’t see the connection between a config type and its proxy implementation.
Event-handler function type (lightweight observer)#
- How it works:
client/event.Handleris defined astype Handler func(payload any) error. Proxy wrappers call this function onStartProxy/CloseProxylifecycle transitions. Theproxy.Manager.handleEventmethod type-switches onanyto dispatchStartProxyPayloadvsCloseProxyPayload. - Example:
client/proxy/proxy_wrapper.go:187—_ = pw.handler(&event.CloseProxyPayload{…});client/proxy/proxy_manager.go:109—case *event.StartProxyPayload: - Assessment: Functional approach avoids a full observer interface with multiple methods. The
anypayload requires a type switch, which is slightly fragile (unhandled types returnErrPayloadType). A small, bounded set of payload types makes this acceptable.
Interface embedding for protocol promotion#
- How it works:
Proxy,Visitor,Connector,Plugin, andTunnelSessionare small, focused interfaces.BaseProxyis a concrete struct embedded into each proxy type for shared behavior (work-conn wrapping, encryption, rate limiting, logging). This is inheritance-by-composition: the embed provides implementations; the interface defines the contract. - Example:
client/proxy/proxy.go:49—Proxy interface(4 methods); each proxy type likeTCPProxyembeds*BaseProxyand satisfiesProxyby implementing only the type-specific methods.
io.Reader/Writer decoration for cross-cutting concerns#
- Pattern:
pkg/util/limitwraps anyio.Readerorio.Writerwith rate-limiting logic. Encryption (pkg/util/net) wraps connections in a similar decorator chain. - Assessment: Standard Go “onion” composition. The proxy’s work-connection setup in
BaseProxy.wrapWorkConncomposes: raw conn → optional encryption stream → rate-limited reader/writer — all without modifying the underlying transport.
Type assertions via type switch for message dispatch#
- Usage: 28 type-assertion/switch occurrences; primarily in
msg.Dispatcherhandlers and event routing - Example:
pkg/msg— the dispatcher registers handlers perreflect.Typeof the incoming message; message routing uses a type switch overanyin event handlers - Assessment: The message types are a closed, version-controlled set, so the exhaustive type switch is safe and readable.
Generics#
- Usage: None — the project targets Go 1.22 (from
go.mod) but does not use generics. All polymorphism is achieved via interfaces and reflection.