frp — Patterns#

Concurrency patterns#

Goroutine-per-component lifecycle#

  • Usage: 45 go func occurrences; every major subsystem is launched as a named goroutine in Run() methods
  • Example: server/service.gogo svr.HandleListener(listener), go webServer.Run(), go quicListener.Run(), etc.
  • Assessment: Clean and idiomatic. Each subsystem goroutine receives a context.Context and 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:102pxy.readCh = make(chan *msg.UDPPacket, 1024) / pxy.sendCh = make(chan msg.Message, 1024)
  • Example (signal): client/proxy/proxy_wrapper.go:112closeCh: 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 select blocks — 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. select is 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.Context occurrences; every long-running goroutine receives a context; context.WithCancel / context.WithCancelCause used at service boundaries
  • Example: client/service.go:225ctx, cancel := context.WithCancelCause(ctx); cancellation reason is stored and surfaced via StatusExporter for 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.Limiter wrapped in io.Reader (pkg/util/limit/reader.go) and io.Writer (pkg/util/limit/writer.go)
  • Example: client/proxy/proxy.go:65-68 — if BandwidthLimit is configured, a rate.Limiter is injected into BaseProxy and the work connection is wrapped at I/O time
  • Assessment: Elegant composition. Rate limiting is applied transparently at the io.Reader/io.Writer level without touching proxy logic. The limit.Reader and limit.Writer call limiter.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:51closeOnce sync.Once ensures connector.Close() is idempotent even when called concurrently; server/metrics/metrics.go:20registerMetrics sync.Once ensures 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/atomic in 3 production files; total 80 sync-primitive references
  • Example: client/proxy/proxy_wrapper.go:203atomic.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:72lastPong atomic.Value for heartbeat timestamp
  • Assessment: Conservative and correct use. Health status and heartbeat time are read/written by separate goroutines without requiring a mutex; atomic.Value stores time.Time safely.

Error handling#

  • Style: Mixed: sentinel package-level var Err* = errors.New(…) combined with fmt.Errorf("%w: …", sentinel) wrapping, and occasional plain fmt.Errorf without %w
  • Error types defined:
    • client/health/health.goErrHealthCheckType
    • client/event/event.goErrPayloadType
    • client/visitor/xtcp.goErrNoTunnelSession
    • client/configmgmt/types.goErrInvalidArgument, ErrNotFound, ErrConflict, ErrStoreDisabled, ErrApplyConfig
    • pkg/util/http/error.goError struct (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 in client/config_manager.go; allows callers to use errors.Is(err, configmgmt.ErrConflict) for type-safe discrimination. errors.As and errors.Is are both used at call sites.
  • Examples:
    • client/config_manager.go:28fmt.Errorf("%w: frpc has no config file path", configmgmt.ErrInvalidArgument)
    • client/proxy/proxy.go:143fmt.Errorf("create encryption stream error: %w", err)
    • client/config_manager_test.go:52errors.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 via config.RegisterServerConfigFlags. For the service initialization API, a ServiceOptions struct acts as a “named parameter bag” — the Go idiomatic alternative to functional options when there are many fields.
  • Example (ServiceOptions bag):
    // 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.goWithSSHMode() RegisterFlagOption). Not used for production service construction.
  • Hot reload: source.Aggregator watches a file-backed ConfigSource and calls proxy.Manager.UpdateAll / visitor.Manager.UpdateAll to 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 fields
    • ResourceController acts as a “dependency bundle” struct passed into every server.Control at construction time, avoiding an ever-growing constructor argument list
    • SessionContext is a “context object” pattern: a struct bundling authenticated session metadata (runID, login info, resource controller) that is threaded into per-session components
    • ServiceOptions.ConnectorCreator is a first-class function field that lets callers inject a custom Connector factory — used by pkg/virtual to create in-process VirtualClient with 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:
    // client/proxy/general_tcp.go
    func init() {
        RegisterProxyFactory(reflect.TypeOf(&v1.TCPProxyConfig{}), newGeneralTCPProxy)
    }
    The registry is a map[reflect.Type]func(*BaseProxy, v1.ProxyConfigurer) Proxy. NewProxy looks 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.gocreators 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 of reflect.TypeOf as 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.Handler is defined as type Handler func(payload any) error. Proxy wrappers call this function on StartProxy / CloseProxy lifecycle transitions. The proxy.Manager.handleEvent method type-switches on any to dispatch StartProxyPayload vs CloseProxyPayload.
  • Example: client/proxy/proxy_wrapper.go:187_ = pw.handler(&event.CloseProxyPayload{…}); client/proxy/proxy_manager.go:109case *event.StartProxyPayload:
  • Assessment: Functional approach avoids a full observer interface with multiple methods. The any payload requires a type switch, which is slightly fragile (unhandled types return ErrPayloadType). A small, bounded set of payload types makes this acceptable.

Interface embedding for protocol promotion#

  • How it works: Proxy, Visitor, Connector, Plugin, and TunnelSession are small, focused interfaces. BaseProxy is 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:49Proxy interface (4 methods); each proxy type like TCPProxy embeds *BaseProxy and satisfies Proxy by implementing only the type-specific methods.

io.Reader/Writer decoration for cross-cutting concerns#

  • Pattern: pkg/util/limit wraps any io.Reader or io.Writer with 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.wrapWorkConn composes: 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.Dispatcher handlers and event routing
  • Example: pkg/msg — the dispatcher registers handlers per reflect.Type of the incoming message; message routing uses a type switch over any in 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.