Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,36 @@ failures and close failures are Debug; bare EOF and errors wrapping
`net.ErrClosed` are quiet. A wrapped EOF still records a truncated read at Debug.
Peer event-queue overflow produces a Warn record.

Accepted connections opt into RFC 3539 §3.4.1 and Appendix A watchdog supervision
with `sm.Settings.EnableWatchdog`; dialed connections use `sm.Client.EnableWatchdog`.
Both default to off; conforming deployments should enable the watchdog, as
[RFC 6733 §5.5.3](https://www.rfc-editor.org/rfc/rfc6733.html#section-5.5.3) requires
support for the algorithm. `WatchdogInterval` is Twinit: default 30 seconds,
minimum 6 seconds, with ±2 seconds of jitter on each reset. Received traffic
resets Tw. For a silent peer, successive expiries send one DWR, report SUSPECT,
then close at DOWN (about 84–96 seconds with the defaults). `OnWatchdogConnEvent`
identifies each accepted connection; SUSPECT reports failover for the application
to handle (RFC 6733 §5.5.4). Supervision starts after successful CEA and
`OnHandshake`, and stops on DPR/Disconnect when entering Closing (RFC 6733 §5.6).
Supervised DWAs are consumed by the state machine, so a user `"DWA"` route does
not see them. After supervision ends, late DWAs follow normal dispatch and,
without a matching route, are logged as unhandled answers.
After peer FIN, `DispatchDone` waits for in-flight concurrent handlers; until
they finish, the watchdog may run and report events for that departing peer.
The state machine does not reconnect; `peer.Manager` manages peer reconnection
and has its own watchdog configuration (`Timers.TwInit`, `OnPeerEvent`). Choose
Twinit to tolerate handler latency: saturated concurrent dispatch delays reads.
`Server.WriteTimeout` bounds socket writes, including DWR; it does not bound
callbacks or custom `Conn` wrappers. A blocked DWR write delays the watchdog's
own progress, DWA crediting, and Disconnect/DPR stop. Ordinary inbound dispatch
can continue. `OnWatchdogConnEvent` must return promptly and must not call
`Disconnect` synchronously or wait for any inbound message on the same connection;
both can deadlock. Connection wrappers must retain the same `diam.Conn` identity
across messages. For SCTP, the default
watchdog stream 0 preserves CEA ordering. Choosing a different stream before a
post-CEA message confirms the initiator reached I-Open can let DWR overtake CEA
(RFC 6733 §2.1.1); select streams with that establishment constraint in mind.

Use `CloseNotify` for disconnects, watchdog hooks for liveness, `OnPeerEvent` for
managed peers, and an `ALL` handler for unmatched messages. `Settings.OnHandshake`
runs once after successful CER/CEA exchange while admission is held. It must not
Expand Down
14 changes: 7 additions & 7 deletions diam/nonblocking_send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import (
// channel's purpose rather than source line numbers, which drift on edits.
func TestNonblockingSendsHaveDocumentedReason(t *testing.T) {
allowed := map[string]string{
"server.go:c.idle": "Idle notification coalesces; the consumer reads the current activity state.",
"sm/disconnect.go:p.result": "Disconnect completion is a one-shot result; a buffered result already completes the waiter.",
"sm/dwa.go:dwac": "Watchdog acknowledgement is a coalescing wake-up, not an error report.",
"sm/client.go:h.activity.signal": "Read activity coalesces; activity state is stored separately.",
"peer/session.go:s.ingress": "Queue overflow is reported and the connection closes.",
"peer/session.go:s.writes": "Queue overflow is returned to the writer for reporting and closure.",
"peer/manager.go:m.callbackQ": "Dropped peer events are explicitly logged before returning.",
"server.go:c.idle": "Idle notification coalesces; the consumer reads the current activity state.",
"sm/disconnect.go:p.result": "Disconnect completion is a one-shot result; a buffered result already completes the waiter.",
"sm/dwa.go:dwac": "Watchdog acknowledgement is a coalescing wake-up, not an error report.",
"sm/watchdog.go:s.signal": "Read activity coalesces; activity state is stored separately.",
"peer/session.go:s.ingress": "Queue overflow is reported and the connection closes.",
"peer/session.go:s.writes": "Queue overflow is returned to the writer for reporting and closure.",
"peer/manager.go:m.callbackQ": "Dropped peer events are explicitly logged before returning.",
}
seen := make(map[string]bool)
files := token.NewFileSet()
Expand Down
21 changes: 15 additions & 6 deletions diam/peer/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ type PeerConfig struct {
type Config struct {
// Settings supplies local capabilities and decoding policy. Manager owns the
// base protocol; New rejects OnCER, OnCEA, OnDWR, OnDWA, OnDPR and
// OnHandshake hooks. Use OnPeerEvent to observe managed peers.
// OnHandshake hooks, and EnableWatchdog, WatchdogInterval, WatchdogStream,
// OnWatchdogConnEvent. Use Timers.TwInit and OnPeerEvent for managed peers.
Settings sm.Settings
Clock Clock
Timers Timers
Expand Down Expand Up @@ -114,14 +115,22 @@ type Manager struct {

func New(cfg Config) (*Manager, error) {
for _, hook := range []struct {
name string
set bool
name string
set bool
watchdog bool
}{
{"OnCER", cfg.Settings.OnCER != nil}, {"OnCEA", cfg.Settings.OnCEA != nil},
{"OnDWR", cfg.Settings.OnDWR != nil}, {"OnDWA", cfg.Settings.OnDWA != nil},
{"OnDPR", cfg.Settings.OnDPR != nil}, {"OnHandshake", cfg.Settings.OnHandshake != nil},
{"OnCER", cfg.Settings.OnCER != nil, false}, {"OnCEA", cfg.Settings.OnCEA != nil, false},
{"OnDWR", cfg.Settings.OnDWR != nil, false}, {"OnDWA", cfg.Settings.OnDWA != nil, false},
{"OnDPR", cfg.Settings.OnDPR != nil, false}, {"OnHandshake", cfg.Settings.OnHandshake != nil, false},
{"EnableWatchdog", cfg.Settings.EnableWatchdog, true},
{"WatchdogInterval", cfg.Settings.WatchdogInterval != 0, true},
{"WatchdogStream", cfg.Settings.WatchdogStream != 0, true},
{"OnWatchdogConnEvent", cfg.Settings.OnWatchdogConnEvent != nil, true},
} {
if hook.set {
if hook.watchdog {
return nil, fmt.Errorf("peer: Settings.%s is not supported; Manager supervises every peer (use Timers.TwInit and OnPeerEvent)", hook.name)
}
return nil, fmt.Errorf("peer: Settings.%s is not supported; use OnPeerEvent", hook.name)
}
}
Expand Down
32 changes: 32 additions & 0 deletions diam/peer/state_machine_watchdog_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package peer

import (
"strings"
"testing"
"time"

"github.com/gomaja/go-diameter/diam"
"github.com/gomaja/go-diameter/diam/sm"
)

func TestNewRejectsStateMachineWatchdogSettings(t *testing.T) {
for _, tc := range []struct {
name string
set func(*sm.Settings)
}{
{"EnableWatchdog", func(s *sm.Settings) { s.EnableWatchdog = true }},
{"WatchdogInterval", func(s *sm.Settings) { s.WatchdogInterval = time.Second }},
{"WatchdogStream", func(s *sm.Settings) { s.WatchdogStream = 3 }},
{"OnWatchdogConnEvent", func(s *sm.Settings) { s.OnWatchdogConnEvent = func(diam.Conn, sm.WatchdogEvent) {} }},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := Config{Settings: testSettings("local.example.net")}
tc.set(&cfg.Settings)
_, err := New(cfg)
want := "peer: Settings." + tc.name + " is not supported; Manager supervises every peer (use Timers.TwInit and OnPeerEvent)"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("New error = %v, want %q", err, want)
}
})
}
}
193 changes: 193 additions & 0 deletions diam/sm/accepted_watchdog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package sm

import (
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"

"github.com/gomaja/go-diameter/diam"
"github.com/gomaja/go-diameter/diam/internal/base"
)

type watchdogPolicy struct {
twinit time.Duration
jitter time.Duration
stream uint
onEvent func(diam.Conn, WatchdogEvent)
}

func acceptedWatchdogPolicy(cfg *Settings) (*watchdogPolicy, error) {
if !cfg.EnableWatchdog {
return nil, nil
}
twinit := cfg.WatchdogInterval
if twinit == 0 {
twinit = 30 * time.Second
}
floor, jitter := cfg.watchdogTiming.parameters()
// RFC 3539 §3.4.1 [1]: Twinit defaults to 30s and MUST NOT be below 6s.
if twinit < floor {
return nil, fmt.Errorf("watchdog interval %s is below RFC 3539 §3.4.1 minimum %s", twinit, floor)
}
return &watchdogPolicy{twinit: twinit, jitter: jitter, stream: cfg.WatchdogStream, onEvent: cfg.OnWatchdogConnEvent}, nil
}

type acceptedWatchdog struct {
connWatchdog
signals watchdogSignals
mu sync.Mutex
closing sync.WaitGroup // admitted terminal work completes before stop returns
stopped atomic.Bool
stopc chan struct{}
stopOnce sync.Once
exited chan struct{}
dwa diam.Handler
policy *watchdogPolicy
}

func (sm *StateMachine) startWatchdog(c diam.Conn) {
if sm.watchdog == nil || c.Closed() {
return
}
done := connectionDone(c)
if done == nil {
logMessage(c, nil, slog.LevelWarn, "sm: watchdog disabled: connection exposes neither DispatchDone nor CloseNotifier", nil)
return
}
w := &acceptedWatchdog{
signals: newWatchdogSignals(), stopc: make(chan struct{}), exited: make(chan struct{}), policy: sm.watchdog,
}
w.connWatchdog = connWatchdog{
conn: c, done: done, stop: w.stopc, signals: &w.signals, dwac: w.signals.dwac,
interval: func() time.Duration { return jitteredTw(w.policy.twinit, w.policy.jitter) },
send: func() bool {
m, err := base.BuildDWR(c.Dictionary(), baseSettings(sm.cfg), uint32(sm.cfg.OriginStateID))
if err != nil {
w.terminate(nil, slog.LevelError, "sm: watchdog request failed; closing connection", err)
return false
}
return publishDWR(c, m, w.policy.stream, &w.mu, &w.stopped, w.emit, func(err error) {
w.terminate(m, slog.LevelError, "sm: watchdog request failed; closing connection", err)
})
},
observe: func(event WatchdogEvent) { w.observe(c, event) },
timeout: func() { w.terminate(nil, slog.LevelWarn, "sm: watchdog timeout; closing connection", nil) },
}
dwa := handleDWA(sm, w.signals.dwac, func(_ diam.Conn, event WatchdogEvent) { w.emit(event) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject mismatched DWAs before clearing Pending

The accepted supervisor passes every syntactically successful DWA to handleDWA, but it never records the DWR's Hop-by-Hop/End-to-End IDs, and handleDWA therefore clears Pending for unsolicited, duplicated, or stale answers. For example, a delayed DWA from an earlier probe arriving while a newer DWR is outstanding credits the newer request and postpones or prevents the SUSPECT/DOWN transition. The managed-peer watchdog already enforces this correlation in diam/peer/watchdog.go; this path should likewise retain the outstanding IDs and reject answers that do not match.

Useful? React with 👍 / 👎.

w.dwa = diam.HandlerFunc(func(c diam.Conn, m *diam.Message) {
w.mu.Lock()
defer w.mu.Unlock()
// RFC 6733 §5.6: Closing has no DWA event, even through a stale
// supervisor pointer. Ignore it before parsing or crediting Pending.
if w.stopped.Load() {
return
}
dwa.ServeDIAM(c, m)
})
if _, loaded := sm.watchdogs.LoadOrStore(c, w); loaded {
return
}
// RFC 6733 §5.6: Disconnect may enter Closing during the CEA write or
// OnHandshake. It registers pending before stopping supervision, so the
// later of this registration and Disconnect observes the other. If
// Disconnect already removed pending, Closed retains that terminal state.
sm.disconnects.mu.Lock()
pending := sm.disconnects.pending[c] != nil
sm.disconnects.mu.Unlock()
if pending || c.Closed() {
w.stop()
sm.watchdogs.CompareAndDelete(c, w)
close(w.exited)
return
}
// RFC 3539 Appendix A: an initially admitted connection starts in OKAY.
// RFC 6733 §2.1: reconnecting belongs to the connecting node, not this
// responder; StateMachine keeps no per-peer control block for REOPEN.
go func() {
defer close(w.exited)
defer sm.watchdogs.CompareAndDelete(c, w)
defer w.stop()
w.run()
}()
}

func (sm *StateMachine) supervised(c diam.Conn) *acceptedWatchdog {
if sm.watchdog == nil {
return nil
}
w, ok := sm.watchdogs.Load(c)
if !ok {
return nil
}
return w.(*acceptedWatchdog)
}

func (sm *StateMachine) stopWatchdog(c diam.Conn) {
w := sm.supervised(c)
if w == nil {
return
}
w.stop()
}

func (w *acceptedWatchdog) stop() {
// RFC 6733 §5.6: Closing has no DWR/DWA events. Holding the publication
// mutex guarantees no further DWR write, DWA credit or watchdog event.
// Wait for terminal logging and Close before returning as well.
w.mu.Lock()
w.stopped.Store(true)
w.stopOnce.Do(func() { close(w.stopc) })
w.mu.Unlock()
w.closing.Wait()
}

func (w *acceptedWatchdog) received() {
// RFC 6733 §5.6: received traffic cannot restart a Closing watchdog.
// Do not wait for a DWR write or callback before releasing admission.
// A racing activity store is inert: the loop stops and events are gated.
if w.stopped.Load() {
return
}
w.signals.received()
}

func (w *acceptedWatchdog) terminate(m *diam.Message, level slog.Level, text string, err error) {
// RFC 6733 §5.6: terminal watchdog work either completes before stop
// returns or is suppressed. Leave the event mutex free during Close.
w.mu.Lock()
if w.stopped.Load() {
w.mu.Unlock()
return
}
// Add and stop's stopped.Store share mu: no terminal work can be
// admitted after stop begins waiting, even when the count was zero.
w.closing.Add(1)
w.mu.Unlock()
defer w.closing.Done()
logMessage(w.conn, m, level, text, err)
w.conn.Close()
}

func (w *acceptedWatchdog) observe(_ diam.Conn, event WatchdogEvent) {
w.mu.Lock()
defer w.mu.Unlock()
w.emit(event)
}

// emit runs with mu held, including when publishDWR publishes a request.
func (w *acceptedWatchdog) emit(event WatchdogEvent) {
// RFC 6733 §5.6: serialize all events with stop, including a timer or
// message handler already in flight when the connection enters Closing.
if w.stopped.Load() || w.policy.onEvent == nil {
return
}
defer func() {
if p := recover(); p != nil {
logMessage(w.conn, nil, slog.LevelError, "sm: watchdog event callback panicked; closing connection", fmt.Errorf("%v", p))
w.conn.Close()
}
}()
w.policy.onEvent(w.conn, event)
}
Loading
Loading