Files
Paul Lorenz d562afe1d1 Detect slow control channel handlers, off unless configured on
Control channel send back-pressure was showing up as a p99 at the send timeout,
with no way to tell which handler was holding the receive goroutine or what it
was waiting on. A dump taken after the fact records the goroutine unwinding the
diagnostic rather than whatever it was blocked on, so the snapshot has to happen
while the handler is still in the handler. That costs a timer per message, which
is why this is opt-in rather than always on.

- wraps every control channel receive handler on both sides, timing it and
  snapshotting all goroutines while a slow one is still in the handler
- installs nothing when disabled. A nil detector's Wrap returns the handler it
  was given, so a process that has not asked for this pays no timer, no clock
  read and no branch per message, rather than paying a check
- deduplicates dumps by a normalized signature so a process stuck in one place
  writes one file and counts the repeats, rather than filling the disk with the
  same picture
- names the subject of each dump, since the goroutine being diagnosed cannot be
  picked out of the dump and would otherwise be filtered out as a singleton
- makes the thresholds and the dump tracking configurable, because what to dump
  on varies by what is being chased: 500ms finds a wedged handler, lock
  contention wants tens of milliseconds, and a rare event wants more distinct
  dumps kept and less time between them
- builds one detector per process rather than per channel. Per channel would
  quietly turn the dump interval and the budget of distinct dumps into per
  connection limits, and those limits are what bound the disk a dump can cost
- refuses settings that would produce nothing, such as a zero threshold or no
  dumps allowed, but only when enabled, so a config left over from an
  investigation does not stop a process starting once it is switched off
- reports the router's own control channel state

It lives in common/diagnostics, shared by the controller and router rather than
duplicated per side, and is documented commented-out in the sample configs.
2026-09-03 12:39:57 -04:00

217 lines
8.4 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package handler_ctrl
import (
"github.com/google/uuid"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/channel/v5"
"github.com/openziti/ziti/v2/common/capabilities"
"github.com/openziti/ziti/v2/common/ctrlchan"
"github.com/openziti/ziti/v2/common/diagnostics"
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
"github.com/openziti/ziti/v2/controller/change"
"github.com/openziti/ziti/v2/controller/network"
"github.com/openziti/ziti/v2/controller/xctrl"
"github.com/pkg/errors"
"google.golang.org/protobuf/proto"
)
type CtrlAccepter struct {
network *network.Network
xctrls []xctrl.Xctrl
options *channel.Options
heartbeatOptions *channel.HeartbeatOptions
traceHandler *channel.TraceHandler
slowHandlers *diagnostics.SlowHandlerDetector
}
func NewCtrlAccepter(network *network.Network,
xctrls []xctrl.Xctrl,
options *channel.Options,
heartbeatOptions *channel.HeartbeatOptions,
traceHandler *channel.TraceHandler,
slowHandlers diagnostics.SlowHandlerConfig) *CtrlAccepter {
return &CtrlAccepter{
network: network,
xctrls: xctrls,
options: options,
heartbeatOptions: heartbeatOptions,
traceHandler: traceHandler,
slowHandlers: diagnostics.NewSlowHandlerDetector("ctrl", slowHandlers),
}
}
// NewMultiListener returns a HelloAcceptor that handles both grouped (multi-underlay) and
// ungrouped (single underlay) connections from routers. As a HelloAcceptor it defers the
// hello acknowledgement until the group is registered, closing the race where a second
// underlay for the same group could arrive before the group was known.
func (self *CtrlAccepter) NewMultiListener() channel.HelloAcceptor {
return channel.NewMultiListener(self.HandleGroupedUnderlay, self.AcceptUnderlay)
}
// HandleGroupedUnderlay handles incoming grouped connections from routers that support
// multi-underlay control channels. It creates a MultiChannel with ListenerCtrlChannel.
func (self *CtrlAccepter) HandleGroupedUnderlay(underlay channel.Underlay, closeCallback func()) (channel.Channel, error) {
if _, hasSecret := underlay.Headers()[channel.GroupSecretHeader]; !hasSecret {
underlay.Headers()[channel.GroupSecretHeader] = []byte(uuid.NewString())
}
listenerCtrlChan := ctrlchan.NewListenerCtrlChannel()
multiConfig := channel.Config{
LogicalName: "ctrl/" + underlay.Id(),
Options: self.options,
Underlay: underlay,
Binder: channel.MakeBinder(channel.BindHandlerF(func(binding channel.Binding) error {
binding.AddCloseHandler(channel.CloseHandlerF(func(ch channel.Channel) {
closeCallback()
}))
return self.Bind(binding)
})),
Senders: listenerCtrlChan,
MessageSourceProvider: listenerCtrlChan,
UnderlayEventListeners: []channel.UnderlayEventListener{listenerCtrlChan},
// Multi-underlay-capable so the high/low-priority underlays are accepted;
// MinTotalUnderlays closes the channel only when its last underlay is lost.
Constraints: listenerCtrlChan.GetConstraints(),
MinTotalUnderlays: 1,
}
mc, err := channel.NewChannel(&multiConfig)
if err != nil {
pfxlog.Logger().WithError(err).Errorf("failure accepting ctrl channel %v with multi-underlay", underlay.Label())
return nil, err
}
return mc, nil
}
// AcceptUnderlay handles incoming ungrouped connections from routers that don't support
// multi-underlay control channels (backward compatibility).
func (self *CtrlAccepter) AcceptUnderlay(underlay channel.Underlay) error {
_, err := self.HandleGroupedUnderlay(underlay, func() {})
return err
}
func (self *CtrlAccepter) Bind(binding channel.Binding) error {
binding.GetChannel().SetLogicalName(binding.GetChannel().Id())
ch := binding.GetChannel()
log := pfxlog.Logger().WithField("routerId", ch.Id())
// A fresh instance per connection, carrying this channel: that is what lets connect and disconnect tell
// two connections for one router apart, and keeps them from writing over each other's state.
r, err := self.network.NewCtrlChanRouter(ch)
if err != nil {
return err
}
var ctrlChanListeners map[string][]string
if ch.Underlay().Headers() != nil {
if versionValue, found := ch.Underlay().Headers()[channel.HelloVersionHeader]; found {
if versionInfo, err := self.network.VersionProvider.EncoderDecoder().Decode(versionValue); err == nil {
r.VersionInfo = versionInfo
log = log.WithField("version", r.VersionInfo.Version).
WithField("revision", r.VersionInfo.Revision).
WithField("buildDate", r.VersionInfo.BuildDate).
WithField("os", r.VersionInfo.OS).
WithField("arch", r.VersionInfo.Arch)
} else {
return errors.Wrap(err, "could not parse version info from router hello, not accepting router connection")
}
} else {
return errors.New("no version info header, not accepting router connection")
}
r.SetLinkListeners(nil)
headers := ch.Underlay().Headers()
// Determine header locations based on router capabilities. 2.0+ routers
// send a CapabilitiesHeader with RouterMultiChannel set and use header IDs
// in the 1000+ range. Pre-2.0 routers use legacy IDs (10-12) and don't
// send a CapabilitiesHeader.
r.Capabilities = capabilities.GetCapabilities[capabilities.RouterCapability](headers)
useNewHeaders := r.Capabilities.IsSet(capabilities.RouterMultiChannel)
listenersHeaderId := ctrl_pb.LegacyListenersHeader
if useNewHeaders {
listenersHeaderId = int32(ctrl_pb.ControlHeaders_ListenersHeader)
}
if val, found := headers[listenersHeaderId]; found {
listeners := &ctrl_pb.Listeners{}
if err = proto.Unmarshal(val, listeners); err != nil {
log.WithError(err).Error("unable to unmarshall listeners value")
} else {
r.SetLinkListeners(listeners.Listeners)
for _, listener := range listeners.Listeners {
log.WithField("address", listener.GetAddress()).
WithField("protocol", listener.GetProtocol()).
Debug("router listener")
}
}
} else {
log.Debug("no advertised listeners")
}
if val, found := ch.Underlay().Headers()[int32(ctrl_pb.ControlHeaders_CtrlChanListenersHeader)]; found {
ctrlListeners := &ctrl_pb.CtrlChanListeners{}
if err = proto.Unmarshal(val, ctrlListeners); err != nil {
log.WithError(err).Error("unable to unmarshal ctrl chan listeners value")
} else {
ctrlChanListeners = make(map[string][]string, len(ctrlListeners.Listeners))
for _, listener := range ctrlListeners.Listeners {
ctrlChanListeners[listener.Address] = listener.Groups
}
}
}
changeCtx := change.NewControlChannelChange(r.Id, r.Name, "router.connect", ch)
self.network.Router.UpdateCtrlChanListeners(r, ctrlChanListeners, changeCtx)
} else {
return errors.New("channel provided no headers, not accepting router connection as version info not provided")
}
bindHandler := self.slowHandlers.Wrap(newBindHandler(self.heartbeatOptions, r, self.network, self.xctrls))
if err = bindHandler.BindChannel(binding); err != nil {
return errors.Wrap(err, "error binding router")
}
if self.traceHandler != nil {
binding.AddPeekHandler(self.traceHandler)
}
// Check the router's epoch for stale entry cleanup. If the epoch changed
// (router restarted), delete old-epoch link gossip entries before creating
// new links in ConnectRouter.
if epoch, found := ch.Underlay().Headers()[int32(ctrl_pb.ControlHeaders_EpochHeader)]; found && len(epoch) > 0 {
self.network.HandleRouterEpoch(r.Id, epoch)
}
if err = self.network.QueueRouterConnect(r); err != nil {
if network.IsConnectRejected(err) {
log.Info("router connect rejected; another connection is already current, router will redial")
}
// Returning the error fails the bind, so NewChannel closes this channel's underlay without
// starting rx or registering it (rejected connect) — and a full connect pool likewise makes the
// router redial. Either way the rx-gate is preserved for a connection that was not accepted.
return err
}
log.Info("accepted new router connection")
return nil
}