From d21c2beced7b7439bcb8a1570726e9e3d4589bfd Mon Sep 17 00:00:00 2001 From: gomaja Date: Fri, 16 Oct 2026 11:39:04 +0200 Subject: [PATCH] refactor(peer): run Manager connections through the protocol core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer.Manager made its own pre-CER, packet, control, watchdog and retirement decisions for every connection it owned. Each dialed and accepted connection generation now runs through one private protocol core, the one StateMachine and Client already use. Manager keeps what lies above a single connection: peer authorization and selection, election (RFC 6733 §5.6.4), dialing, routing, the shared identifier space, queues, pending requests and failover (RFC 6733 §5.5.4), reconnection, notifications, renewal and retirement. Native connections are still supervised through DispatchDone, and the public API does not change. Behaviour changes, each with a test that fails on main: - Inbound activity that did not refresh the watchdog interval on main now does, without counting toward health: a malformed or repeated CER, other recoverable decode errors, an invalid header, invalid grammar, a request rejected by optional validation, a wrong bound host, an unsolicited CEA or DPA, and decoded traffic while reopening. - During local Closing, the first DPR received records the peer's cause; a later DPR no longer replaces reconnect suppression. - For every adapter, a request rejected during Closing is now dropped before its error answer is built. When building that answer failed, StateMachine and Client used to close the connection, report ProtocolReplyFailed and end Disconnect with ErrDisconnectClosed; Closing now continues to its DPA or deadline. Where Manager still differs from StateMachine, a named core option selects each diagnostic or disposition policy. A differential test of StateMachine and Client against main matches 3,758 of 3,760 observations; the two differences are the Closing change above. Core effects shrink from 1,208 to 304 bytes and are appended into storage the caller lends; a successful queued answer completes without waking the Manager's actor; each core keeps one spare operation record. BenchmarkInboundMessages at one processor, bytes and allocations per exchange, main → this change (dialed exchanges match accepted ones): | Exchange | B/op | allocs/op | | ------------------------------------------ | ------------ | --------- | | peer Accepted/ApplicationRequestRejection | 7627 → 6394 | 141 → 126 | | peer Accepted/CorrelatedApplicationAnswer | 5441 → 5169 | 121 → 118 | | peer Accepted/UnsolicitedApplicationAnswer | 3312 → 3104 | 59 → 57 | | peer Accepted/WatchdogExchange | 3928 → 2264 | 75 → 57 | | sm Accepted/ApplicationRequest | 11041 → 4128 | 60 → 59 | | sm Accepted/ApplicationAnswer | 9760 → 2848 | 23 → 22 | | sm Accepted/WatchdogExchange | 14409 → 5176 | 84 → 81 | Closes #112. --- .../internal/parserbridge/parserbridge.go | 2 +- diam/internal/protocol/accessors.go | 25 + .../protocol/candidate_election_test.go | 43 + diam/internal/protocol/closing.go | 73 +- ..._test.go => connection_invariants_test.go} | 0 diam/internal/protocol/control.go | 79 +- ...go => control_completion_evidence_test.go} | 0 diam/internal/protocol/core.go | 179 ++-- diam/internal/protocol/core_test.go | 2 +- diam/internal/protocol/deferred_cer_test.go | 38 + .../protocol/diagnostic_policy_test.go | 236 +++++ diam/internal/protocol/effect_storage_test.go | 210 +++++ .../protocol/fatal_callback_activity_test.go | 44 + diam/internal/protocol/handshake.go | 69 +- diam/internal/protocol/handshake_result.go | 18 +- .../protocol/header_fatal_policy_test.go | 104 +++ .../protocol/initiator_lifecycle_test.go | 4 +- .../protocol/local_disconnect_cause_test.go | 58 ++ diam/internal/protocol/matrix_test.go | 13 +- .../protocol/message_error_callback_test.go | 105 +++ diam/internal/protocol/operation_storage.go | 11 + .../protocol/operation_storage_test.go | 96 ++ diam/internal/protocol/queued_reply.go | 27 + diam/internal/protocol/queued_reply_test.go | 89 ++ diam/internal/protocol/receive.go | 209 +++-- .../protocol/rejection_evidence_test.go | 72 ++ .../protocol/reopen_unhandled_test.go | 20 + .../protocol/repeated_cer_fatal_test.go | 68 ++ .../protocol/reply_build_cause_test.go | 90 ++ diam/internal/protocol/types.go | 90 +- .../internal/protocol/unowned_control_test.go | 165 ++++ diam/internal/protocol/watchdog.go | 104 ++- .../protocol/watchdog_timer_pair_test.go | 10 +- diam/peer/actor.go | 477 ++-------- diam/peer/answer_correlation_test.go | 72 +- ...est.go => base_message_validation_test.go} | 12 +- diam/peer/capability_order_test.go | 5 + diam/peer/cea_election_failure_test.go | 53 ++ diam/peer/closing_wire_test.go | 4 +- ....go => control_answer_diagnostics_test.go} | 28 +- diam/peer/core_activity_wire_test.go | 379 ++++++++ diam/peer/core_disconnect_wire_test.go | 48 + diam/peer/core_fixture_test.go | 196 +++++ diam/peer/core_receive_cost_test.go | 20 + diam/peer/detached_answer_diagnostics_test.go | 256 ++++++ diam/peer/discovery.go | 4 + diam/peer/dwa_diagnostic_contract_test.go | 90 ++ diam/peer/election_reply_delivery_test.go | 198 +++++ diam/peer/error_answer_build_failure_test.go | 199 +++++ diam/peer/error_wrapping_test.go | 24 +- diam/peer/failure_reporting_test.go | 69 +- diam/peer/fatal_repeated_cer_test.go | 42 + diam/peer/handshake_regression_test.go | 53 +- diam/peer/local_disconnect_cause_test.go | 55 ++ diam/peer/malformed_cea_correlation_test.go | 2 +- diam/peer/manager.go | 261 +----- diam/peer/message_cost_bench_test.go | 276 ++++++ diam/peer/message_error_callback_test.go | 299 +++++++ diam/peer/peer_table_core_fixture_test.go | 17 + diam/peer/peer_test.go | 91 +- diam/peer/protocol_effect_storage_test.go | 84 ++ diam/peer/protocol_error.go | 19 - diam/peer/protocol_error_test.go | 136 +-- diam/peer/protocol_test.go | 13 + diam/peer/queued_reply_delivery_test.go | 218 +++++ diam/peer/renewal.go | 15 +- diam/peer/renewal_close_test.go | 7 +- diam/peer/renewal_core_fixture_test.go | 74 ++ diam/peer/renewal_script_test.go | 4 +- diam/peer/renewal_unit_test.go | 121 ++- diam/peer/reporting_core_fixture_test.go | 165 ++++ diam/peer/reporting_test.go | 9 +- diam/peer/request_completion_wire_test.go | 2 +- diam/peer/request_gate_wire_test.go | 4 +- diam/peer/required_reply.go | 27 - diam/peer/required_reply_order_test.go | 52 +- diam/peer/required_reply_test.go | 53 +- diam/peer/restart_fallback_test.go | 8 +- diam/peer/send_test.go | 23 +- diam/peer/session.go | 156 ++-- diam/peer/session_core_test.go | 91 ++ diam/peer/session_order_test.go | 3 + diam/peer/session_protocol.go | 826 ++++++++++++++++++ diam/peer/shutdown_core_obligation_test.go | 74 ++ diam/peer/validation.go | 49 -- diam/peer/watchdog.go | 243 +----- diam/peer/watchdog_delivery_test.go | 33 +- diam/peer/watchdog_fuzz_test.go | 11 +- diam/peer/watchdog_test.go | 219 +++-- diam/peer/writer_completion_test.go | 46 + diam/sm/accepted_core.go | 4 +- diam/sm/accepted_effect_storage_test.go | 4 +- diam/sm/accepted_effects.go | 2 +- diam/sm/accepted_log.go | 5 +- diam/sm/client_core.go | 6 +- diam/sm/closing_request_rejection_test.go | 183 ++++ ...test.go => control_diagnostic_log_test.go} | 2 +- diam/sm/rejection_construction_test.go | 237 +++++ diam/sm/smparser/cea.go | 2 +- diam/sm/smparser/cer.go | 2 +- diam/sm/smparser/cer_bridge_test.go | 2 +- 101 files changed, 7067 insertions(+), 1750 deletions(-) rename diam/{sm => }/internal/parserbridge/parserbridge.go (88%) create mode 100644 diam/internal/protocol/candidate_election_test.go rename diam/internal/protocol/{review_regression_test.go => connection_invariants_test.go} (100%) rename diam/internal/protocol/{round2_test.go => control_completion_evidence_test.go} (100%) create mode 100644 diam/internal/protocol/deferred_cer_test.go create mode 100644 diam/internal/protocol/diagnostic_policy_test.go create mode 100644 diam/internal/protocol/effect_storage_test.go create mode 100644 diam/internal/protocol/fatal_callback_activity_test.go create mode 100644 diam/internal/protocol/header_fatal_policy_test.go create mode 100644 diam/internal/protocol/local_disconnect_cause_test.go create mode 100644 diam/internal/protocol/message_error_callback_test.go create mode 100644 diam/internal/protocol/operation_storage.go create mode 100644 diam/internal/protocol/operation_storage_test.go create mode 100644 diam/internal/protocol/queued_reply.go create mode 100644 diam/internal/protocol/queued_reply_test.go create mode 100644 diam/internal/protocol/rejection_evidence_test.go create mode 100644 diam/internal/protocol/reopen_unhandled_test.go create mode 100644 diam/internal/protocol/repeated_cer_fatal_test.go create mode 100644 diam/internal/protocol/reply_build_cause_test.go create mode 100644 diam/internal/protocol/unowned_control_test.go rename diam/peer/{base_validation_round2_test.go => base_message_validation_test.go} (96%) create mode 100644 diam/peer/cea_election_failure_test.go rename diam/peer/{protocol_error_round2_test.go => control_answer_diagnostics_test.go} (91%) create mode 100644 diam/peer/core_activity_wire_test.go create mode 100644 diam/peer/core_disconnect_wire_test.go create mode 100644 diam/peer/core_fixture_test.go create mode 100644 diam/peer/core_receive_cost_test.go create mode 100644 diam/peer/detached_answer_diagnostics_test.go create mode 100644 diam/peer/dwa_diagnostic_contract_test.go create mode 100644 diam/peer/election_reply_delivery_test.go create mode 100644 diam/peer/error_answer_build_failure_test.go create mode 100644 diam/peer/fatal_repeated_cer_test.go create mode 100644 diam/peer/local_disconnect_cause_test.go create mode 100644 diam/peer/message_cost_bench_test.go create mode 100644 diam/peer/message_error_callback_test.go create mode 100644 diam/peer/peer_table_core_fixture_test.go create mode 100644 diam/peer/protocol_effect_storage_test.go create mode 100644 diam/peer/queued_reply_delivery_test.go create mode 100644 diam/peer/renewal_core_fixture_test.go create mode 100644 diam/peer/reporting_core_fixture_test.go create mode 100644 diam/peer/session_core_test.go create mode 100644 diam/peer/session_protocol.go create mode 100644 diam/peer/shutdown_core_obligation_test.go delete mode 100644 diam/peer/validation.go create mode 100644 diam/peer/writer_completion_test.go create mode 100644 diam/sm/closing_request_rejection_test.go rename diam/sm/{accepted_log_compat_test.go => control_diagnostic_log_test.go} (97%) create mode 100644 diam/sm/rejection_construction_test.go diff --git a/diam/sm/internal/parserbridge/parserbridge.go b/diam/internal/parserbridge/parserbridge.go similarity index 88% rename from diam/sm/internal/parserbridge/parserbridge.go rename to diam/internal/parserbridge/parserbridge.go index 85cfb6c..e4fac4e 100644 --- a/diam/sm/internal/parserbridge/parserbridge.go +++ b/diam/internal/parserbridge/parserbridge.go @@ -1,4 +1,4 @@ -// Package parserbridge gives package sm the smparser conversions that are not +// Package parserbridge gives the protocol adapters the smparser conversions that are not // part of smparser's public API. smparser installs each one during its // initialization; this package cannot import smparser, so each is held as any // with its concrete function type documented beside it. diff --git a/diam/internal/protocol/accessors.go b/diam/internal/protocol/accessors.go index 4a4cc10..817b1c2 100644 --- a/diam/internal/protocol/accessors.go +++ b/diam/internal/protocol/accessors.go @@ -1,8 +1,33 @@ package protocol +import ( + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/watchdog" +) + // Phase returns the current phase within the caller's serialization domain. func (c *Core) Phase() Phase { return c.phase } // Ready reports whether admission callbacks have completed within the caller's // serialization domain. func (c *Core) Ready() bool { return c.ready } + +// Progress carries scalar protocol state for executors that publish peer +// eligibility. Capability/error evidence is copied only by Snapshot. +type Progress struct { + Phase Phase + Ready bool + Watchdog watchdog.Machine + Timers [timerCount]TimerSnapshot + Controls [3]ControlSnapshot +} + +func (c *Core) Progress() Progress { + p := Progress{Phase: c.phase, Ready: c.ready, Watchdog: c.wd, Timers: c.timers} + for i, command := range [...]uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { + if r := c.controls[command]; r != nil { + p.Controls[i] = ControlSnapshot{Command: r.command, Reservation: r.reservation, Logical: r.logical, Answered: r.answered, Writers: r.writers} + } + } + return p +} diff --git a/diam/internal/protocol/candidate_election_test.go b/diam/internal/protocol/candidate_election_test.go new file mode 100644 index 0000000..b5824a5 --- /dev/null +++ b/diam/internal/protocol/candidate_election_test.go @@ -0,0 +1,43 @@ +package protocol + +import ( + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/watchdog" +) + +func TestCandidateActivationUsesPeerRecoveryHistory(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Watchdog = true; cfg.WatchdogInterval = 6 * time.Second }) + c.Handle(event(Start)) + candidate := find(t, c.Handle(receive(cer(t))), ValidatedCER) + in := event(Activate) + in.Token, in.Recovery = candidate.Token, true + sent := find(t, c.Handle(in), Send) + ready := find(t, c.Handle(complete(sent)), HandshakeReady) + in = event(ReleaseAdmission) + in.Token = ready.Token + probe := grant(t, c, c.Handle(in)) + if probe.Message.Header.CommandCode != diam.DeviceWatchdog || c.Snapshot().Watchdog().State != watchdog.Reopen { + t.Fatal("recovered accepted peer skipped REOPEN") + } +} + +func TestCandidateTransfersDeadlineToElectionOwner(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.CandidateDeadlineOwnedByPeer = true }) + c.Handle(event(Start)) + before := c.Snapshot().Timer(HandshakeTimer) + es := c.Handle(receive(cer(t))) + find(t, es, ValidatedCER) + if c.Snapshot().Timer(HandshakeTimer).Token != 0 { + t.Fatal("candidate retained the pre-CER deadline") + } + in := event(TimerExpired) + in.Timer = HandshakeTimer + in.Token = before.Token + in.At = before.Deadline + if got := c.Handle(in); len(got) != 0 || c.Snapshot().Candidate() == 0 { + t.Fatal("pre-CER timeout cancelled the election candidate") + } +} diff --git a/diam/internal/protocol/closing.go b/diam/internal/protocol/closing.go index 4aedcd8..c28cb9b 100644 --- a/diam/internal/protocol/closing.go +++ b/diam/internal/protocol/closing.go @@ -7,9 +7,9 @@ import ( "github.com/gomaja/go-diameter/diam/internal/base" ) -// beginClosing latches before DPR/DPA effects. Frozen acknowledges the #125 -// native write-gate boundary, not queue selection (RFC 6733 §§5.4,5.6). -func (c *Core) beginClosing(in Input, local bool) []Effect { +// beginClosing latches before DPR/DPA effects. Frozen acknowledges the native +// write-gate boundary before selecting disconnect control. +func (c *Core) beginClosing(es []Effect, in Input, local bool) []Effect { c.phase = Closing c.ready = false c.closeLocal = local @@ -20,45 +20,45 @@ func (c *Core) beginClosing(in Input, local bool) []Effect { c.disconnectWaiting = true c.disconnectToken = in.Token } - es := c.stop(WatchdogTimer) + es = c.stop(es, WatchdogTimer) duration := in.Timeout if duration <= 0 { duration = c.cfg.ClosingTimeout } - es = append(es, c.arm(ClosingTimer, in.At, duration)...) + es = c.arm(es, ClosingTimer, in.At, duration) e := c.effect(FreezeAdmission) e.Token = c.fenceToken return append(es, e) } -func (c *Core) freeze(in Input) []Effect { +func (c *Core) freeze(es []Effect, in Input) []Effect { if c.phase != Closing || c.frozen || in.Token != c.fenceToken { - return nil + return es } c.frozen = true c.fenceToken = 0 if c.closeLocal { - return c.reserve(diam.DisconnectPeer) + return c.reserve(es, diam.DisconnectPeer) } saved := c.closeRequest c.closeRequest = Input{} saved.InFlight = in.InFlight - return c.answerDPR(saved, true) + return c.answerDPR(es, saved, true) } -func (c *Core) disconnect(in Input) []Effect { +func (c *Core) disconnect(es []Effect, in Input) []Effect { if c.disconnectWaiting { - return []Effect{c.notify(Notification{Kind: DisconnectNotice, Outcome: AlreadyPending})} + return append(es, c.notify(Notification{Kind: DisconnectNotice, Outcome: AlreadyPending})) } // Preserve StateMachine's local API after a received DPR (RFC 6733 // §§5.4.1,5.6). Neither the original receiver deadline nor admission changes. if c.cfg.LocalDisconnectInClosing && c.phase == Closing && c.frozen && !c.closeLocal && in.Cause <= 2 { c.disconnectWaiting, c.disconnectToken, c.closeCause = true, in.Token, in.Cause - es := c.arm(ReceiverDisconnectTimer, in.At, in.Timeout) - return append(es, c.reserve(diam.DisconnectPeer)...) + es = c.arm(es, ReceiverDisconnectTimer, in.At, in.Timeout) + return c.reserve(es, diam.DisconnectPeer) } - if c.phase != Open || in.Cause > 2 { - return []Effect{c.notify(Notification{Kind: DisconnectNotice, Outcome: NotOpen})} + if c.phase != Open || in.Cause > 2 && !c.cfg.AllowAnyLocalDisconnectCause { + return append(es, c.notify(Notification{Kind: DisconnectNotice, Outcome: NotOpen})) } - return c.beginClosing(in, true) + return c.beginClosing(es, in, true) } func (c *Core) bound(m *diam.Message, realm bool) bool { var identity base.DWR @@ -67,18 +67,19 @@ func (c *Core) bound(m *diam.Message, realm bool) bool { } return equalIdentity(string(identity.OriginHost), c.caps.Host) && (!realm || equalIdentity(string(identity.OriginRealm), c.caps.Realm)) } -func (c *Core) dpr(in Input) (es []Effect) { +func (c *Core) dpr(es []Effect, in Input) (out []Effect) { cause, err := base.ValidateDPR(in.Message) if err == nil && !c.bound(in.Message, false) { err = errors.New("DPR Origin-Host does not match the admitted peer (RFC 6733 §6.3)") } if err != nil { - return c.discardCause(in, diam.ProtocolInvalidDPR, err) + return c.discardCause(es, in, diam.ProtocolInvalidDPR, err) } in.Cause = cause + start := len(es) defer func() { - if len(es) != 0 { - es[0].ReleaseDispatch = true + if len(out) > start { + out[start].ReleaseDispatch = true } }() // RFC 6733 §5.4.3: reconnect suppression is decided by the validated @@ -90,34 +91,34 @@ func (c *Core) dpr(in Input) (es []Effect) { es = append(es, e) } if c.phase != Closing { - es = append(es, c.validActivity()...) - return append(es, c.beginClosing(in, false)...) + es = c.validActivity(es) + return c.beginClosing(es, in, false) } if !c.frozen { - return append(es, c.discard(in, "", 0)...) + return c.discard(es, in, "", 0) } - return append(es, c.answerDPR(in, !c.peerDisconnectSelected)...) + return c.answerDPR(es, in, !c.peerDisconnectSelected) } -func (c *Core) answerDPR(in Input, first bool) []Effect { +func (c *Core) answerDPR(es []Effect, in Input, first bool) []Effect { result := uint32(diam.Success) if in.InFlight { result = diam.UnableToComply } answer, err := base.BuildDPAResult(in.Message, c.cfg.Local, result) if err != nil { - return c.fail(in, diam.ProtocolReplyFailure, result) + return c.fail(es, in, diam.ProtocolReplyFailure, result) } var notice *Notification if first { c.peerDisconnectSelected = true notice = &Notification{Kind: PeerDisconnectNotice, Cause: c.peerCause, ReplyCode: result, Message: metadata(in.Message)} } - return c.send(answer, nil, true, false, false, notice, in.Message) + return c.send(es, answer, nil, true, false, false, notice, in.Message) } -func (c *Core) dpa(in Input) []Effect { +func (c *Core) dpa(es []Effect, in Input) []Effect { r := c.controls[diam.DisconnectPeer] if !c.matches(r, in.Message) { - es := c.discard(in, diam.ProtocolUnexpectedMessage, 0) + es = c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) if r != nil && r.logical { es[len(es)-1].Notification.Diagnostic = "dpa-correlation" } @@ -125,14 +126,14 @@ func (c *Core) dpa(in Input) []Effect { } result, err := base.ValidateDPA(in.Message) if err != nil { - return c.discardCause(in, diam.ProtocolInvalidDPA, err) + return c.discardCause(es, in, diam.ProtocolInvalidDPA, err) } if !c.bound(in.Message, false) { - es := c.discard(in, diam.ProtocolUnexpectedMessage, 0) + es = c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) es[len(es)-1].Notification.Diagnostic = "dpa-host" return es } - es := c.settle(r) + es = c.settle(es, r) // Synchronous frontends complete their DPR send before publishing the // disconnect result. An early answer settles ownership without cancelling // the original Closing deadline or the selected writer (#110 API boundary). @@ -143,14 +144,14 @@ func (c *Core) dpa(in Input) []Effect { c.earlyDPA = &saved return es } - return append(es, c.completeDPA(in, result)...) + return c.completeDPA(es, in, result) } -func (c *Core) completeDPA(in Input, result uint32) []Effect { +func (c *Core) completeDPA(es []Effect, in Input, result uint32) []Effect { c.earlyDPA = nil - var es []Effect + c.disconnectWaiting = false - es = append(es, c.terminate(Closed)...) + es = c.terminate(es, Closed) n := Notification{Kind: DisconnectNotice, ResultCode: result, Outcome: Completed} e := c.notify(n) e.Token = c.disconnectToken diff --git a/diam/internal/protocol/review_regression_test.go b/diam/internal/protocol/connection_invariants_test.go similarity index 100% rename from diam/internal/protocol/review_regression_test.go rename to diam/internal/protocol/connection_invariants_test.go diff --git a/diam/internal/protocol/control.go b/diam/internal/protocol/control.go index f26f2ff..0768275 100644 --- a/diam/internal/protocol/control.go +++ b/diam/internal/protocol/control.go @@ -36,32 +36,32 @@ func metadata(m *diam.Message) diam.ProtocolMessageMetadata { } return out } -func (c *Core) reserve(command uint32) []Effect { +func (c *Core) reserve(es []Effect, command uint32) []Effect { e := c.effect(ReserveIDs) e.Command = command e.Token = c.token() c.reservations[e.Token] = command - return []Effect{e} + return append(es, e) } -func (c *Core) reserved(in Input) []Effect { +func (c *Core) reserved(es []Effect, in Input) []Effect { cmd, ok := c.reservations[in.Token] if !ok { - return nil + return es } delete(c.reservations, in.Token) if in.Err != nil { if c.phase >= Closed { - return nil + return es } if cmd == diam.CapabilitiesExchange { c.handshake = newHandshakeResult(in.Err, nil) } - return c.terminate(Closed) + return c.terminate(es, Closed) } if c.phase >= Closed || cmd == diam.DeviceWatchdog && c.phase != Open { e := c.effect(ReleaseIDs) e.Reservation = in.Reservation - return []Effect{e} + return append(es, e) } var m *diam.Message var err error @@ -79,17 +79,22 @@ func (c *Core) reserved(in Input) []Effect { if cmd == diam.CapabilitiesExchange { c.handshake = newHandshakeResult(err, nil) } - return c.terminate(Closed) + return c.terminate(es, Closed) } m.Header.HopByHopID = in.Reservation.Hop m.Header.EndToEndID = in.Reservation.End r.header = *m.Header r.frame = copyMessage(m) - return c.send(m, r, false, false, false, nil) + return c.send(es, m, r, false, false, false, nil) } -func (c *Core) send(m *diam.Message, r *control, required, closeAfter, handshake bool, notice *Notification, source ...*diam.Message) []Effect { +func (c *Core) send(es []Effect, m *diam.Message, r *control, required, closeAfter, handshake bool, notice *Notification, source ...*diam.Message) []Effect { token := c.token() - op := &operation{control: r, length: int64(m.Len()), required: required, closeAfter: closeAfter, handshake: handshake, notice: notice} + op := c.spareOperation + c.spareOperation = nil + if op == nil { + op = new(operation) + } + *op = operation{control: r, length: int64(m.Len()), required: required, closeAfter: closeAfter, handshake: handshake, notice: notice} if notice != nil { op.message = notice.Message } @@ -114,11 +119,11 @@ func (c *Core) send(m *diam.Message, r *control, required, closeAfter, handshake } else { e.Stage = WrittenStage } - return []Effect{e} + return append(es, e) } -func (c *Core) release(r *control) []Effect { +func (c *Core) release(es []Effect, r *control) []Effect { if r.logical || r.writers != 0 || r.released { - return nil + return es } r.released = true if c.controls[r.command] == r { @@ -126,21 +131,25 @@ func (c *Core) release(r *control) []Effect { } e := c.effect(ReleaseIDs) e.Reservation = r.reservation - return []Effect{e} + return append(es, e) } -func (c *Core) settle(r *control) []Effect { r.logical = false; r.answered = true; return c.release(r) } -func (c *Core) writeResult(in Input) []Effect { +func (c *Core) settle(es []Effect, r *control) []Effect { + r.logical = false + r.answered = true + return c.release(es, r) +} +func (c *Core) writeResult(es []Effect, in Input) []Effect { if in.Stage > SkippedStage { - return nil + return es } op := c.operations[in.Token] if op == nil { - return nil + return es } if in.Stage == SkippedStage && op.control != nil { - return c.skippedControl(in, op) + return c.skippedControl(es, in, op) } - var es []Effect + terminal := in.Stage != QueueStage || in.Err != nil err := in.Err if in.Stage == SkippedStage && err == nil { @@ -160,25 +169,26 @@ func (c *Core) writeResult(in Input) []Effect { } if in.Stage == QueueStage { if op.queued { - return nil + return es } op.queued = err == nil } if terminal { delete(c.operations, in.Token) + defer c.recycleOperation(op) if op.control != nil { op.control.writers-- - es = append(es, c.release(op.control)...) + es = c.release(es, op.control) } } if c.phase >= Closed { return es } if err != nil { - if c.cfg.CERTransportLossCompatibility && c.phase == WaitCEA && op.control != nil && op.control.command == diam.CapabilitiesExchange { + if c.cfg.KeepCERDeadlineAfterTransportLoss && c.phase == WaitCEA && op.control != nil && op.control.command == diam.CapabilitiesExchange { c.handshake = newHandshakeResult(err, nil) } - es = append(es, c.terminate(Closed)...) + es = c.terminate(es, Closed) if op.required { n := Notification{Protocol: diam.ProtocolReplyFailure, Reply: diam.ProtocolReplyFailed, ReplyCode: in.ReplyCode, Closing: true, Message: op.message} if !op.reported && op.notice != nil && op.notice.Protocol != "" { @@ -191,10 +201,10 @@ func (c *Core) writeResult(in Input) []Effect { } if in.Stage == WrittenStage && op.control != nil && op.control.command == diam.DisconnectPeer && c.earlyDPA != nil { saved := *c.earlyDPA - return append(es, c.completeDPA(saved, saved.ResultCode)...) + return c.completeDPA(es, saved, saved.ResultCode) } if op.closeAfter && in.Stage == WrittenStage { - es = append(es, c.terminate(Closed)...) + es = c.terminate(es, Closed) } boundary := in.Stage == WrittenStage || c.cfg.QueueWrites if !boundary || op.reported { @@ -217,10 +227,10 @@ func (c *Core) writeResult(in Input) []Effect { } if op.control != nil && op.control.logical { if op.control.command == diam.DeviceWatchdog { - es = append(es, c.watchdogPublished(in)...) + es = c.watchdogPublished(es, in) } if op.control.command == diam.CapabilitiesExchange && c.cfg.CERInterval > 0 { - es = append(es, c.arm(RetryTimer, in.At, c.cfg.CERInterval)...) + es = c.arm(es, RetryTimer, in.At, c.cfg.CERInterval) } } return es @@ -228,16 +238,17 @@ func (c *Core) writeResult(in Input) []Effect { // #108/#125 and RFC 6733 §§3,5.6: gate cancellation ends a selected writer, // not the connection. A published CER still owns correlation across retries. -func (c *Core) skippedControl(in Input, op *operation) []Effect { +func (c *Core) skippedControl(es []Effect, in Input, op *operation) []Effect { delete(c.operations, in.Token) + defer c.recycleOperation(op) r := op.control logical := r.logical r.writers-- if r.writers == 0 && !r.written { r.logical = false } - es := c.release(r) - if c.cfg.CERTransportLossCompatibility && c.phase == WaitCEA && r.command == diam.CapabilitiesExchange { + es = c.release(es, r) + if c.cfg.KeepCERDeadlineAfterTransportLoss && c.phase == WaitCEA && r.command == diam.CapabilitiesExchange { // A native closed gate is still the synchronous CER attempt's public // error. Its skipped writer pin has already been disposed above. err := in.Err @@ -245,13 +256,13 @@ func (c *Core) skippedControl(in Input, op *operation) []Effect { err = io.ErrShortWrite } c.handshake = newHandshakeResult(err, nil) - return append(es, c.terminate(Closed)...) + return c.terminate(es, Closed) } if r.command == diam.DeviceWatchdog && !r.written && logical { c.wdNext = nil c.wd.Pending = false if c.cfg.Watchdog && c.phase == Open && c.timers[WatchdogTimer].Token == 0 { - es = append(es, c.watchdogArm(in.At, in.Interval)...) + es = c.watchdogArm(es, in.At, in.Interval) } } return es diff --git a/diam/internal/protocol/round2_test.go b/diam/internal/protocol/control_completion_evidence_test.go similarity index 100% rename from diam/internal/protocol/round2_test.go rename to diam/internal/protocol/control_completion_evidence_test.go diff --git a/diam/internal/protocol/core.go b/diam/internal/protocol/core.go index 04e693d..39df72e 100644 --- a/diam/internal/protocol/core.go +++ b/diam/internal/protocol/core.go @@ -41,6 +41,7 @@ type Core struct { phase Phase serial uint64 started, ready, terminal, frozen bool + cerDeferred bool passiveResponder, passiveCER bool passiveWatchdog bool passivePresent bool @@ -53,6 +54,7 @@ type Core struct { reservations map[uint64]uint32 controls map[uint32]*control operations map[uint64]*operation + spareOperation *operation closeCause uint32 wd watchdog.Machine } @@ -87,6 +89,7 @@ func New(cfg Config) (*Core, error) { } return c, nil } + func (c *Core) Snapshot() Snapshot { s := Snapshot{phase: c.phase, connection: c.cfg.Connection, role: c.cfg.Role, timers: c.timers, candidate: c.candidateToken, admission: c.admission, ready: c.ready, frozen: c.frozen, capabilities: copyCapabilities(c.caps), wd: c.wd} s.peerCause, s.peerDPR = c.peerCause, c.peerDPR @@ -101,28 +104,30 @@ func (c *Core) Snapshot() Snapshot { func (c *Core) token() uint64 { c.serial++; return c.serial } func (c *Core) effect(k EffectKind) Effect { return Effect{Kind: k, Connection: c.cfg.Connection} } -func (c *Core) arm(k TimerKind, at time.Time, d time.Duration) []Effect { +func (c *Core) arm(es []Effect, k TimerKind, at time.Time, d time.Duration) []Effect { if d <= 0 { - return nil + return es } e := c.effect(ArmTimer) e.Timer = k e.Token = c.token() e.Deadline = at.Add(d) c.timers[k] = TimerSnapshot{e.Token, e.Deadline} - return []Effect{e} + return append(es, e) } -func (c *Core) stop(k TimerKind) []Effect { + +func (c *Core) stop(es []Effect, k TimerKind) []Effect { if c.timers[k].Token == 0 { - return nil + return es } e := c.effect(StopTimer) e.Timer = k e.Token = c.timers[k].Token c.timers[k] = TimerSnapshot{} - return []Effect{e} + return append(es, e) } -func (c *Core) terminate(phase Phase) []Effect { + +func (c *Core) terminate(es []Effect, phase Phase) []Effect { c.phase = phase c.earlyDPA = nil c.candidateToken, c.hookToken, c.admission = 0, 0, 0 @@ -136,14 +141,14 @@ func (c *Core) terminate(phase Phase) []Effect { c.wd, _ = c.wd.Step(watchdog.ConnectionDown) c.wd.Pending = false } - var es []Effect + for k := HandshakeTimer; k < timerCount; k++ { - es = append(es, c.stop(k)...) + es = c.stop(es, k) } for _, cmd := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { if r := c.controls[cmd]; r != nil { r.logical = false - es = append(es, c.release(r)...) + es = c.release(es, r) } } var tokens []uint64 @@ -171,28 +176,38 @@ func (c *Core) terminate(phase Phase) []Effect { } return es } + +// Handle returns a batch whose storage is independent of later calls. func (c *Core) Handle(in Input) []Effect { + return c.HandleInto(in, nil) +} + +// HandleInto appends obligations to dst in execution order. The caller owns dst +// and must finish consuming a batch before reusing its storage. Core never +// retains the output slice; concurrent calls still require caller serialization. +func (c *Core) HandleInto(in Input, dst []Effect) []Effect { + es := dst if in.Connection != c.cfg.Connection { - return nil + return es } if in.Kind == ReserveResult { - return c.reserved(in) + return c.reserved(es, in) } if in.Kind == WriteResult { - return c.writeResult(in) + return c.writeResult(es, in) } if c.phase == Closed && in.Kind == Continue && in.Token == c.lateCERToken && c.lateCERToken != 0 { c.lateCERToken = 0 saved := c.lateCER c.lateCER = Input{} if in.TransportClosed { - return nil + return es } n := Notification{Protocol: diam.ProtocolHandshakeState, Closing: true, Message: metadata(saved.Message), Diagnostic: "late-cer"} - return []Effect{c.effect(Close), c.notify(n)} + return append(es, c.effect(Close), c.notify(n)) } if c.phase >= Closed { - return nil + return es } if in.RefreshLocalAddresses && (c.phase == PreCER || c.passiveResponder && in.ErrorInput) { c.cfg.Local.HostIPAddresses = base.CloneAddresses(in.LocalAddresses) @@ -201,36 +216,48 @@ func (c *Core) Handle(in Input) []Effect { switch in.Kind { case Start: if c.started || c.phase != PreCER { - return nil + return es } c.started = true - es := c.arm(HandshakeTimer, in.At, c.cfg.HandshakeTimeout) + if c.cfg.Role == Initiator && in.DeferCER { + c.phase = WaitCEA + c.cerDeferred = true + return es + } + es = c.arm(es, HandshakeTimer, in.At, c.cfg.HandshakeTimeout) if c.cfg.Role == Initiator { c.phase = WaitCEA c.handshakePending = true - es = append(es, c.reserve(diam.CapabilitiesExchange)...) + es = c.reserve(es, diam.CapabilitiesExchange) } return es + case BeginCER: + if !c.cerDeferred || c.phase != WaitCEA { + return es + } + c.cerDeferred = false + return c.reserve(c.arm(es, HandshakeTimer, in.At, c.cfg.HandshakeTimeout), diam.CapabilitiesExchange) case StopSupervision: c.cfg.Watchdog = false c.wd.Pending = false c.wdNext = nil - return c.stop(WatchdogTimer) + return c.stop(es, WatchdogTimer) case Retire: - return c.terminate(Retired) + return c.terminate(es, Retired) case Abort: c.closeOutcome = Cancelled - return c.terminate(Closed) + return c.terminate(es, Closed) case TransportLost: - if c.cfg.CERTransportLossCompatibility && c.phase == WaitCEA { + if c.cfg.KeepCERDeadlineAfterTransportLoss && c.phase == WaitCEA { // EOF preserves the CER response window. Its deadline selects a // retry or completes the handshake with timeout. - return nil + return es } - return c.terminate(Closed) + return c.terminate(es, Closed) case Receive: - es := c.receive(in) - if len(es) == 1 && es[0].Kind == Hold { + start := len(es) + es = c.receive(es, in) + if len(es) == start+1 && es[start].Kind == Hold { return es } if in.Sequence > c.receivedSequence { @@ -239,13 +266,13 @@ func (c *Core) Handle(in Input) []Effect { if c.deferredExpiry != nil && c.deferredExpiry.AfterReceive <= c.receivedSequence { pending := *c.deferredExpiry c.deferredExpiry = nil - es = append(es, c.expire(pending)...) + es = c.expire(es, pending) } return es case Continue: pending, ok := c.continuations[in.Token] if !ok { - return nil + return es } saved := pending.Input delete(c.continuations, in.Token) @@ -253,91 +280,92 @@ func (c *Core) Handle(in Input) []Effect { c.hookToken = 0 } if in.TransportClosed { - return c.terminate(Closed) + return c.terminate(es, Closed) } original := *saved.Message.Header if in.Message != nil { saved.Message = in.Message } if saved.Message == nil || saved.Message.Header == nil { - return c.fail(in, diam.ProtocolMalformedMessage, 0) + return c.fail(es, in, diam.ProtocolMalformedMessage, 0) } h := saved.Message.Header // Hooks may change AVPs, but cannot turn their continuation into a // different base exchange or replace its reply identifiers (#108). if h.CommandCode != original.CommandCode || h.ApplicationID != original.ApplicationID || h.CommandFlags&diam.RequestFlag != original.CommandFlags&diam.RequestFlag || h.HopByHopID != original.HopByHopID || h.EndToEndID != original.EndToEndID { - return c.fail(saved, diam.ProtocolInvalidHeader, diam.InvalidHDRBits) + return c.fail(es, saved, diam.ProtocolInvalidHeader, diam.InvalidHDRBits) } // A hook cannot reclassify a repeated CER as a fresh admission when // frontend metadata is revoked meanwhile (RFC 6733 §5.6). if pending.repeatedCER { - return c.discard(saved, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, saved, diam.ProtocolUnexpectedMessage, 0) } - if original.CommandCode == diam.DeviceWatchdog && c.cfg.DWRHookCompatibility { - return c.dwr(saved) + if original.CommandCode == diam.DeviceWatchdog && c.cfg.SkipDWRGrammarAfterHook { + return c.dwr(es, saved) } if err := base.ValidateMessage(saved.Message); err != nil { if original.CommandCode == diam.CapabilitiesExchange { - return c.reject(saved, diam.ProtocolCERRejected, err) + return c.reject(es, saved, diam.ProtocolCERRejected, err) } - return c.discard(saved, diam.ProtocolInvalidDWR, err.ResultCode) + return c.discard(es, saved, diam.ProtocolInvalidDWR, err.ResultCode) } if original.CommandCode == diam.DeviceWatchdog { - return c.dwr(saved) + return c.dwr(es, saved) } - return c.candidate(saved) + return c.candidate(es, saved) case Activate: - return c.activate(in) + return c.activate(es, in) case RejectCandidate: if c.candidateToken == 0 || in.Token != c.candidateToken { - return nil + return es } if in.ResultCode == 0 { - return c.terminate(Closed) + return c.terminate(es, Closed) } c.candidateToken = 0 c.terminal = true answer, err := base.BuildCEA(c.cer, c.cfg.Local, in.ResultCode) if err != nil { in.Message = c.cer - return c.failedRejection(in, diam.ProtocolCERRejected, in.ResultCode) + return c.failedRejection(es, in, diam.ProtocolCERRejected, in.ResultCode, err) } notice := Notification{Protocol: diam.ProtocolCERRejected, ResultCode: in.ResultCode, Closing: true, Message: metadata(c.cer)} - return c.send(answer, nil, true, true, false, ¬ice) + return c.send(es, answer, nil, true, true, false, ¬ice) case ReleaseAdmission: if c.phase != Open || in.Token == 0 || in.Token != c.admission || c.ready || !c.callbackReady { - return nil + return es } c.ready = true c.handshakePending = false - return append([]Effect{c.notify(Notification{Kind: HandshakeNotice})}, c.watchdogUp(in)...) + return c.watchdogUp(append(es, c.notify(Notification{Kind: HandshakeNotice})), in) case Disconnect: - return c.disconnect(in) + return c.disconnect(es, in) case Frozen: - return c.freeze(in) + return c.freeze(es, in) case Unhandled: - return c.unhandled(in) + return c.unhandled(es, in) case TimerExpired: - return c.expire(in) + return c.expire(es, in) } - return nil + return es } -func (c *Core) expire(in Input) []Effect { + +func (c *Core) expire(es []Effect, in Input) []Effect { if in.Timer >= timerCount { - return nil + return es } t := c.timers[in.Timer] if t.Token == 0 || t.Token != in.Token || in.At.Before(t.Deadline) { - return nil + return es } if in.Timer == WatchdogTimer { - return c.watchdogExpired(in) + return c.watchdogExpired(es, in) } if in.Timer == RetryTimer { r := c.controls[diam.CapabilitiesExchange] if r != nil && r.logical && r.attempts <= c.cfg.MaxRetransmits { - es := c.stop(RetryTimer) - return append(es, c.send(copyMessage(r.frame), r, false, false, false, nil)...) + es = c.stop(es, RetryTimer) + return c.send(es, copyMessage(r.frame), r, false, false, false, nil) } } kind := diam.ProtocolHandshakeTimeout @@ -347,10 +375,11 @@ func (c *Core) expire(in Input) []Effect { if in.Timer == ClosingTimer && c.disconnectWaiting && !c.closeLocal && c.timers[ReceiverDisconnectTimer].Token != 0 { // The peer-initiated Closing deadline is transport closure to the // later local caller, not expiry of that caller's own timeout (§5.6). - return append(c.terminate(Closed), c.notify(Notification{Protocol: kind, Closing: true})) + return append(c.terminate(es, Closed), c.notify(Notification{Protocol: kind, Closing: true})) } - return c.fail(in, kind, 0) + return c.fail(es, in, kind, 0) } + func (c *Core) notify(n Notification) Effect { if n.Kind == HandshakeNotice { n.Handshake = c.handshake @@ -359,15 +388,17 @@ func (c *Core) notify(n Notification) Effect { e.Notification = n return e } -func (c *Core) discard(in Input, kind diam.ProtocolErrorKind, code uint32) []Effect { + +func (c *Core) discard(es []Effect, in Input, kind diam.ProtocolErrorKind, code uint32) []Effect { e := c.effect(Discard) - es := []Effect{e} + es = append(es, e) if kind != "" { es = append(es, c.notify(Notification{Protocol: kind, ResultCode: code, Message: metadata(in.Message), CauseError: copyHandshakeError(in.Error)})) } return es } -func (c *Core) fail(in Input, kind diam.ProtocolErrorKind, code uint32) []Effect { + +func (c *Core) fail(es []Effect, in Input, kind diam.ProtocolErrorKind, code uint32) []Effect { var localDPR *diam.Message localTimeout := kind == diam.ProtocolDisconnectTimeout && (c.closeLocal || in.Timer == ReceiverDisconnectTimer) if localTimeout { @@ -385,8 +416,11 @@ func (c *Core) fail(in Input, kind diam.ProtocolErrorKind, code uint32) []Effect if kind == diam.ProtocolHandshakeTimeout || kind == diam.ProtocolDisconnectTimeout || kind == diam.ProtocolWatchdogTimeout { c.closeOutcome = TimedOut } - es := c.terminate(Closed) + es = c.terminate(es, Closed) n := Notification{Protocol: kind, ResultCode: code, Closing: true, Message: metadata(in.Message), CauseError: copyHandshakeError(in.Error)} + if in.Error == nil { + n.CauseError = copyHandshakeError(in.Err) + } if localTimeout { n.Diagnostic = "local-disconnect" } @@ -397,9 +431,12 @@ func (c *Core) fail(in Input, kind diam.ProtocolErrorKind, code uint32) []Effect e.Message = localDPR // log source only; public timeout metadata remains empty return append(es, e) } -func (c *Core) reject(in Input, kind diam.ProtocolErrorKind, me *diam.MessageError) []Effect { + +func (c *Core) reject(es []Effect, in Input, kind diam.ProtocolErrorKind, me *diam.MessageError) []Effect { if c.phase == Closing { - return c.discard(in, kind, me.ResultCode) + es = c.discard(es, in, kind, me.ResultCode) + es[len(es)-1].Notification.CauseError = copyHandshakeError(me) + return es } var failed []*diam.AVP if me.FailedAVP != nil { @@ -407,7 +444,9 @@ func (c *Core) reject(in Input, kind diam.ProtocolErrorKind, me *diam.MessageErr } answer, err := base.BuildErrorAnswer(in.Message, c.cfg.Local, me.ResultCode, failed, me.ResultCode >= 3000 && me.ResultCode < 4000) if err != nil { - return c.failedRejection(in, kind, me.ResultCode) + es = c.failedRejection(es, in, kind, me.ResultCode, err) + es[len(es)-1].Notification.CauseError = copyHandshakeError(me) + return es } terminal := c.phase == PreCER || me.Fatal || in.Message.Header.CommandCode == diam.CapabilitiesExchange && base.ValidateHeader(in.Message) != nil if terminal { @@ -418,15 +457,15 @@ func (c *Core) reject(in Input, kind diam.ProtocolErrorKind, me *diam.MessageErr n.Diagnostic = "reject-cer" } - return c.send(answer, nil, true, terminal, false, &n) + return c.send(es, answer, nil, true, terminal, false, &n) } // #108 required replies preserve the detected condition when construction // fails before any Queued/Written report. A later queued failure is separate. -func (c *Core) failedRejection(in Input, kind diam.ProtocolErrorKind, code uint32) []Effect { +func (c *Core) failedRejection(es []Effect, in Input, kind diam.ProtocolErrorKind, code uint32, err error) []Effect { initialCER := c.phase == PreCER && in.Message != nil && in.Message.Header.CommandCode == diam.CapabilitiesExchange - es := c.terminate(Closed) - n := Notification{Protocol: kind, ResultCode: code, Reply: diam.ProtocolReplyFailed, ReplyCode: code, Closing: true, Message: metadata(in.Message)} + es = c.terminate(es, Closed) + n := Notification{Protocol: kind, ResultCode: code, Reply: diam.ProtocolReplyFailed, ReplyCode: code, Closing: true, Message: metadata(in.Message), ReplyError: copyHandshakeError(err, c.cfg.PreserveReplyErrorFormatWrapper)} if initialCER { n.Diagnostic = "reject-cer" } diff --git a/diam/internal/protocol/core_test.go b/diam/internal/protocol/core_test.go index e30f8f5..d30e305 100644 --- a/diam/internal/protocol/core_test.go +++ b/diam/internal/protocol/core_test.go @@ -64,7 +64,7 @@ func TestRetirementIsPermanent(t *testing.T) { if c.Snapshot().Phase() != Retired { t.Fatal("retirement did not latch") } - for k := Start; k <= Retire; k++ { + for k := Start; k <= BeginCER; k++ { in := event(k) in.Token = 99 wantKinds(t, c.Handle(in)) diff --git a/diam/internal/protocol/deferred_cer_test.go b/diam/internal/protocol/deferred_cer_test.go new file mode 100644 index 0000000..0cc58d1 --- /dev/null +++ b/diam/internal/protocol/deferred_cer_test.go @@ -0,0 +1,38 @@ +package protocol + +import ( + "testing" + "time" +) + +func TestDeferredCERStartsResponseWindowAtPeerCommit(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Role = Initiator }) + in := event(Start) + in.DeferCER = true + wantKinds(t, c.Handle(in)) + if c.Phase() != WaitCEA || c.Snapshot().Timer(HandshakeTimer).Token != 0 { + t.Fatal("deferred startup did not commit without starting the response deadline") + } + in = event(BeginCER) + in.At = epoch.Add(10 * time.Second) + es := c.Handle(in) + timer := find(t, es, ArmTimer) + find(t, es, ReserveIDs) + if timer.Deadline != epoch.Add(40*time.Second) { + t.Fatalf("CER response deadline=%v", timer.Deadline) + } + wantKinds(t, c.Handle(in)) +} + +func TestDeferredCERClosureCompletesBeforePeerCommit(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Role = Initiator }) + in := event(Start) + in.DeferCER = true + c.Handle(in) + es := c.Handle(event(TransportLost)) + n := find(t, es, Notify).Notification + if n.Kind != HandshakeNotice || n.Outcome != ConnectionClosed { + t.Fatal("closure lost the unstarted CER completion") + } + wantKinds(t, c.Handle(event(BeginCER))) +} diff --git a/diam/internal/protocol/diagnostic_policy_test.go b/diam/internal/protocol/diagnostic_policy_test.go new file mode 100644 index 0000000..012d035 --- /dev/null +++ b/diam/internal/protocol/diagnostic_policy_test.go @@ -0,0 +1,236 @@ +package protocol + +import ( + "fmt" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/dict" + "github.com/gomaja/go-diameter/diam/internal/base" +) + +func policyProtocolNotice(t testing.TB, es []Effect) Notification { + t.Helper() + for _, e := range es { + if e.Kind == Notify && e.Notification.Protocol != "" { + return e.Notification + } + } + t.Fatalf("missing protocol notification in %v", kinds(es)) + return Notification{} +} + +func TestReaderOwnsMessageErrorClosure(t *testing.T) { + for _, reader := range []bool{false, true} { + t.Run(fmt.Sprintf("reader=%t", reader), func(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.ReaderOwnsMessageErrorClosure = reader }) + in := receive(request(t, diam.DeviceWatchdog)) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + sent := find(t, c.Handle(in), Send) + if !sent.Required || sent.CloseAfter == reader { + t.Fatalf("fatal reply closure=%t, reader ownership=%t", sent.CloseAfter, reader) + } + n := policyProtocolNotice(t, c.Handle(complete(sent))) + if !n.Closing || (c.Phase() == Closed) == reader { + t.Fatalf("fatality/closure conflated: notice=%+v phase=%v", n, c.Phase()) + } + }) + } +} + +func TestClosingMessageErrorFatalityPolicy(t *testing.T) { + for _, retain := range []bool{false, true} { + t.Run(fmt.Sprintf("retain=%t", retain), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = true + cfg.RetainClosingErrorFatality = retain + }) + c.Handle(event(Disconnect)) + before := c.Snapshot().Timer(ClosingTimer) + in := receive(request(t, diam.DisconnectPeer)) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + n := policyProtocolNotice(t, c.Handle(in)) + if n.Closing != retain || n.Reply != diam.ProtocolReplyNone || c.Phase() != Closing || c.Snapshot().Timer(ClosingTimer) != before { + t.Fatalf("Closing report altered transport ownership/deadline: %+v", n) + } + }) + } +} + +func TestHeaderDiagnosisRetainsSelectedDecoderFatality(t *testing.T) { + for _, retain := range []bool{false, true} { + t.Run(fmt.Sprintf("retain=%t", retain), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = true + cfg.PreserveDecoderFatalityOnHeaderError = retain + }) + in := receive(request(t, diam.DeviceWatchdog)) + in.Message.Header.CommandFlags |= diam.ProxiableFlag + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + sent := find(t, c.Handle(in), Send) + n := policyProtocolNotice(t, c.Handle(complete(sent))) + cause, ok := n.CauseError.(*diam.MessageError) + if !ok || cause.ResultCode != diam.InvalidHDRBits || cause.Fatal != retain || !n.Closing || sent.CloseAfter || c.Phase() != Open { + t.Fatalf("replacement header cause or original report changed: %+v cause=%#v", n, cause) + } + }) + } +} + +func TestHeaderErrorDiagnosticClassificationPolicy(t *testing.T) { + for _, separate := range []bool{false, true} { + for _, closing := range []bool{false, true} { + t.Run(fmt.Sprintf("separate=%t/closing=%t", separate, closing), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = true + cfg.SeparateHeaderErrorDiagnostics = separate + }) + if closing { + c.Handle(event(Disconnect)) + } + in := receive(request(t, diam.DisconnectPeer)) + in.Message.Header.CommandFlags |= diam.ProxiableFlag + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength} + es := c.Handle(in) + if !closing { + es = c.Handle(complete(find(t, es, Send))) + } + n := policyProtocolNotice(t, es) + kind, diagnostic := diam.ProtocolInvalidHeader, "external-message-error" + if separate { + diagnostic = "header-error" + if closing { + kind = diam.ProtocolMalformedMessage + } + } + if n.Protocol != kind || n.Diagnostic != diagnostic || n.ResultCode != diam.InvalidHDRBits { + t.Fatalf("header diagnostic=%+v, want %s/%s", n, kind, diagnostic) + } + }) + } + } +} + +func TestCERMessageErrorClosurePolicy(t *testing.T) { + for _, onlyBits := range []bool{false, true} { + t.Run(fmt.Sprintf("only-invalid-header-bits=%t", onlyBits), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = true + cfg.RejectFatalRepeatedCER = true + cfg.CloseCEROnInvalidHeaderBitsOnly = onlyBits + }) + in := receive(cer(t)) + in.Message.Header.ApplicationID = 4 + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.UnsupportedVersion, Fatal: true} + sent := find(t, c.Handle(in), Send) + if sent.CloseAfter == onlyBits || resultCode(t, sent.Message) != diam.UnsupportedVersion { + t.Fatalf("CER closure=%t for only-bits=%t", sent.CloseAfter, onlyBits) + } + c.Handle(complete(sent)) + if (c.Phase() == Closed) == onlyBits { + t.Fatalf("CER transport outcome=%v for only-bits=%t", c.Phase(), onlyBits) + } + }) + } +} + +func TestCompletedCEAValidationPolicy(t *testing.T) { + for _, validate := range []bool{false, true} { + t.Run(fmt.Sprintf("validate=%t", validate), func(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Role, cfg.ValidateCompletedCEA = Initiator, validate }) + sent := grant(t, c, c.Handle(event(Start))) + c.Handle(complete(sent)) + answer, err := base.BuildCEA(sent.Message, remote(t).Local, diam.Success) + if err != nil { + t.Fatal(err) + } + ready := find(t, c.Handle(receive(answer)), HandshakeReady) + release := event(ReleaseAdmission) + release.Token = ready.Token + c.Handle(release) + answer.AVP = nil + n := policyProtocolNotice(t, c.Handle(receive(answer))) + kind, code := diam.ProtocolUnexpectedMessage, uint32(0) + if validate { + kind, code = diam.ProtocolCEARejected, diam.MissingAVP + } + if n.Protocol != kind || n.ResultCode != code || n.Closing || c.Phase() != Open { + t.Fatalf("completed CEA diagnostic=%+v phase=%v", n, c.Phase()) + } + }) + } +} + +func TestDWAOwnershipPrecedesSemanticDiagnosticsPolicy(t *testing.T) { + for _, ownership := range []bool{false, true} { + t.Run(fmt.Sprintf("ownership-first=%t", ownership), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.Watchdog, cfg.DWAOwnershipBeforeSemantics = true, ownership + cfg.WatchdogInterval = 6 * time.Second + }) + sent := probe(t, c) + answer := dwa(t, sent, diam.UnableToComply) + answer.Header.HopByHopID++ + n := policyProtocolNotice(t, c.Handle(receive(answer))) + kind, code := diam.ProtocolRejectedDWA, uint32(diam.UnableToComply) + if ownership { + kind, code = diam.ProtocolUnexpectedMessage, 0 + } + if n.Protocol != kind || n.ResultCode != code || !c.Snapshot().Watchdog().Pending { + t.Fatalf("unowned DWA changed diagnosis/ownership: %+v", n) + } + }) + } +} + +func TestDWAIdentityPrecedesUnsuccessfulResultPolicy(t *testing.T) { + for _, identity := range []bool{false, true} { + t.Run(fmt.Sprintf("identity-first=%t", identity), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.Watchdog, cfg.DWAIdentityBeforeResult = true, identity + cfg.WatchdogInterval = 6 * time.Second + }) + sent := probe(t, c) + answer := dwa(t, sent, diam.UnableToComply) + for _, field := range answer.AVP { + if field.Code == avp.OriginHost { + field.Data = datatype.DiameterIdentity("other.example") + } + } + n := policyProtocolNotice(t, c.Handle(receive(answer))) + kind, code := diam.ProtocolRejectedDWA, uint32(diam.UnableToComply) + if identity { + kind, code = diam.ProtocolInvalidDWA, 0 + } + if n.Protocol != kind || n.ResultCode != code || !c.Snapshot().Watchdog().Pending { + t.Fatalf("unbound DWA changed diagnosis/ownership: %+v", n) + } + }) + } +} + +func TestReplyBuilderErrorWrapperPolicy(t *testing.T) { + for _, preserve := range []bool{false, true} { + t.Run(fmt.Sprintf("preserve=%t", preserve), func(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.PreserveReplyErrorFormatWrapper = preserve }) + empty, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + in := receive(diam.NewMessage(diam.DeviceWatchdog, diam.RequestFlag, 0, 1, 2, empty)) + es := c.reject(nil, in, diam.ProtocolInvalidRequest, &diam.MessageError{ResultCode: diam.MissingAVP}) + n := policyProtocolNotice(t, es) + if got := fmt.Sprintf("%T", n.ReplyError); (got == "*fmt.wrapError") != preserve { + t.Fatalf("builder wrapper=%s, preserve=%t", got, preserve) + } + }) + } +} diff --git a/diam/internal/protocol/effect_storage_test.go b/diam/internal/protocol/effect_storage_test.go new file mode 100644 index 0000000..586c625 --- /dev/null +++ b/diam/internal/protocol/effect_storage_test.go @@ -0,0 +1,210 @@ +package protocol + +import ( + "bytes" + "fmt" + "reflect" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam/internal/watchdog" + + "github.com/gomaja/go-diameter/diam" +) + +func TestReceiveAppendsOrderedEffectsWithoutAllocating(t *testing.T) { + c := supervised(t, false) + h := c + in := receive(diam.NewMessage(999, 0, 4, 31, 32, c.cfg.Dictionary)) + in.At = epoch.Add(time.Second) + prefix := Effect{Kind: CancelSend, Token: 987} + storage := make([]Effect, 1, 16) + storage[0] = prefix + var out []Effect + allocations := testing.AllocsPerRun(100, func() { out = h.HandleInto(in, storage) }) + if allocations != 0 { + t.Fatalf("reused receive output allocated %g times", allocations) + } + if &out[0] != &storage[0] || out[0] != prefix { + t.Fatal("receive replaced or changed caller prefix") + } + wantKinds(t, out[1:], StopTimer, ArmTimer, Admit) + if out[0].ReleaseDispatch || !out[1].ReleaseDispatch || out[2].ReleaseDispatch || out[3].ReleaseDispatch { + t.Fatal("receive admission boundary moved from the first new effect") + } + if out[3].Message != in.Message { + t.Fatal("application dispatch lost its message") + } + if out[2].Deadline != in.At.Add(6*time.Second) { + t.Fatal("receive timer did not precede dispatch") + } +} + +func TestHandleKeepsEarlierEffectsAcrossLaterEvents(t *testing.T) { + c := supervised(t, false) + in := receive(diam.NewMessage(999, 0, 4, 31, 32, c.cfg.Dictionary)) + first := c.Handle(in) + saved := append([]Effect(nil), first...) + in.At = epoch.Add(time.Second) + second := c.Handle(in) + if !reflect.DeepEqual(first, saved) { + t.Fatal("later Handle overwrote a returned effect batch") + } + second[0].Token = 99999 + if !reflect.DeepEqual(first, saved) { + t.Fatal("returned batches share effect storage") + } +} + +func TestCallerEffectStoragePreservesStateTransitions(t *testing.T) { + for name := range shapes { + for k := Start; k <= BeginCER; k++ { + t.Run(name+"/"+fmt.Sprint(k), func(t *testing.T) { + first, second := matrixSetup(t, name), matrixSetup(t, name) + in := matrixInput(t, name, k, first) + want := first.core.Handle(in) + prefix := Effect{Kind: CancelSend, Token: 987} + storage := make([]Effect, 1, 32) + storage[0] = prefix + got := second.core.HandleInto(in, storage) + if got[0] != prefix { + t.Fatal("state transition changed caller prefix") + } + if len(got)-1 != len(want) { + t.Fatalf("effect count=%d want=%d", len(got)-1, len(want)) + } + for i, e := range want { + actual := got[i+1] + if e.Message != nil && actual.Message != nil { + expectedBytes, err := e.Message.Serialize() + if err != nil { + t.Fatal(err) + } + actualBytes, err := actual.Message.Serialize() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(actualBytes, expectedBytes) || actual.Message.MessageStream() != e.Message.MessageStream() { + t.Fatalf("effect %d message differs", i) + } + actual.Message = e.Message // Dictionary and dispatch internals are not effect payloads. + } + if !reflect.DeepEqual(actual, e) { + t.Fatalf("effect %d differs: got=%+v want=%+v", i, actual, e) + } + } + if !reflect.DeepEqual(first.core.Snapshot(), second.core.Snapshot()) { + t.Fatal("caller storage changed state transition") + } + }) + } + } +} + +func TestEffectPayloadStorageRemainsCompact(t *testing.T) { + size := reflect.TypeFor[Effect]().Size() + t.Logf("Effect storage: %d bytes", size) + if size > 512 { + t.Fatalf("effect occupies %d bytes; optional payloads dominate each obligation", size) + } +} + +func TestHeldReceiveLeavesSequenceAndExpiryPending(t *testing.T) { + c := supervised(t, false) + c.ready = false + expired := event(TimerExpired) + expired.Timer, expired.Token = WatchdogTimer, c.timers[WatchdogTimer].Token + expired.At = c.timers[WatchdogTimer].Deadline + expired.AfterReceive = 1 + c.deferredExpiry = &expired + in := receive(diam.NewMessage(999, 0, 4, 31, 32, c.cfg.Dictionary)) + in.Sequence = 2 + storage := make([]Effect, 1, 4) + storage[0] = Effect{Kind: CancelSend, Token: 987} + out := c.HandleInto(in, storage) + wantKinds(t, out[1:], Hold) + if c.receivedSequence != 0 || c.deferredExpiry != &expired { + t.Fatal("held receive advanced the sequence or consumed its deferred expiry") + } + saved := out[1].Input + if saved == nil || saved.Message == in.Message || saved.Sequence != 2 { + t.Fatal("held receive did not detach its retained input") + } + in.Message.Header.CommandCode = 1000 + out[1] = Effect{} + if saved.Message.Header.CommandCode != 999 { + t.Fatal("held payload changed with source message or effect storage") + } +} + +func TestPublishedSnapshotSurvivesOutputReuse(t *testing.T) { + c := machine(t) + c.Handle(event(Start)) + storage := make([]Effect, 0, 16) + effects := c.HandleInto(receive(cer(t)), storage) + candidate := find(t, effects, ValidatedCER) + before := candidate.Snapshot + in := event(Activate) + in.Token = candidate.Token + effects = c.HandleInto(in, effects[:0]) + published := find(t, effects, Publish).Snapshot + if before == nil || published == nil || before == published || before.Phase() != PreCER || published.Phase() != Open { + t.Fatal("snapshot payloads changed when effect storage was reused") + } + before.capabilities.LocalApplications[0] = 999 + if published.Capabilities().LocalApplications[0] != 4 || c.Snapshot().Capabilities().LocalApplications[0] != 4 { + t.Fatal("snapshot payloads share mutable capability storage") + } +} + +func TestWatchdogRepliesAppendAfterActivityEffects(t *testing.T) { + c := supervised(t, false) + prefix := Effect{Kind: CancelSend, Token: 987} + storage := make([]Effect, 1, 16) + storage[0] = prefix + in := receive(request(t, diam.DeviceWatchdog)) + out := c.HandleInto(in, storage) + wantKinds(t, out[1:], StopTimer, ArmTimer, Send) + if out[0] != prefix || !out[1].ReleaseDispatch { + t.Fatal("DWR changed the caller prefix or dispatch boundary") + } + sent := out[3] + if !sent.AnswerHook || !sent.Required || sent.CloseAfter || sent.Message.Header.CommandFlags&diam.RequestFlag != 0 || sent.Message.Header.CommandCode != diam.DeviceWatchdog { + t.Fatal("DWR lost its required answer obligation") + } + c.Handle(complete(sent)) + probeSend := probe(t, c) + in = receive(dwa(t, probeSend, diam.Success)) + out = c.HandleInto(in, storage) + wantKinds(t, out[1:], StopTimer, ArmTimer, CompleteRequest, ReleaseIDs, Notify) + if out[0] != prefix || !out[1].ReleaseDispatch { + t.Fatal("DWA changed the caller prefix or dispatch boundary") + } + if out[5].Notification.Kind != WatchdogNotice || out[5].Notification.Diagnostic != "answer" || c.Snapshot().Watchdog().Pending || c.Snapshot().Watchdog().State != watchdog.Okay { + t.Fatal("DWA lost its completion or health credit") + } +} + +func TestPeerDisconnectDispatchFollowsReceiptActivity(t *testing.T) { + c := supervised(t, false) + prefix := Effect{Kind: CancelSend, Token: 987} + storage := make([]Effect, 1, 16) + storage[0] = prefix + out := c.HandleInto(receive(request(t, diam.DisconnectPeer)), storage) + wantKinds(t, out[1:], StopTimer, ArmTimer, PeerDisconnectDecision, StopTimer, ArmTimer, FreezeAdmission) + if out[0] != prefix { + t.Fatal("peer disconnect changed the caller prefix") + } + for i, e := range out { + if e.ReleaseDispatch != (i == 3) { + t.Fatalf("peer disconnect dispatch boundary at effect %d is %t", i, e.ReleaseDispatch) + } + } + in := event(Frozen) + in.Token = out[6].Token + out = c.HandleInto(in, out[:1]) + wantKinds(t, out[1:], Send) + if !out[1].Required || out[1].Message.Header.CommandCode != diam.DisconnectPeer || out[1].Message.Header.CommandFlags&diam.RequestFlag != 0 { + t.Fatal("peer disconnect lost its required answer") + } +} diff --git a/diam/internal/protocol/fatal_callback_activity_test.go b/diam/internal/protocol/fatal_callback_activity_test.go new file mode 100644 index 0000000..5e87de7 --- /dev/null +++ b/diam/internal/protocol/fatal_callback_activity_test.go @@ -0,0 +1,44 @@ +package protocol + +import ( + "fmt" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" +) + +func TestFatalBodyCallbackPreservesWatchdogDeadline(t *testing.T) { + for _, fatal := range []bool{false, true} { + for _, header := range []bool{false, true} { + t.Run(fmt.Sprintf("fatal=%t/invalid-header=%t", fatal, header), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = true + cfg.FatalBodyErrorKeepsWatchdogDeadline = true + cfg.Watchdog, cfg.QueueWrites = true, true + cfg.WatchdogInterval = 6 * time.Second + }) + before := c.Snapshot().Timer(WatchdogTimer) + in := receive(request(t, diam.DeviceWatchdog)) + in.At = epoch.Add(time.Second) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: fatal} + if header { + in.Message.Header.CommandFlags |= diam.ProxiableFlag + } + sent := find(t, c.Handle(in), Send) + if sent.CloseAfter || !sent.Required || c.Phase() != Open { + t.Fatal("callback transferred transport closure to the core") + } + after := c.Snapshot().Timer(WatchdogTimer) + if fatal && !header { + if after != before { + t.Fatalf("fatal body callback extended deadline: before=%+v after=%+v", before, after) + } + } else if after.Token == before.Token || !after.Deadline.Equal(in.At.Add(6*time.Second)) { + t.Fatalf("eligible decoded activity did not reset Tw: %+v", after) + } + }) + } + } +} diff --git a/diam/internal/protocol/handshake.go b/diam/internal/protocol/handshake.go index 5b580cd..36f7ec6 100644 --- a/diam/internal/protocol/handshake.go +++ b/diam/internal/protocol/handshake.go @@ -5,21 +5,22 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/watchdog" ) // ErrUnexpectedPeer identifies configured-host rejection after CEA parsing. var ErrUnexpectedPeer = errors.New("protocol: unexpected peer identity") -func (c *Core) candidate(in Input) []Effect { +func (c *Core) candidate(es []Effect, in Input) []Effect { // A genuine CER owns all later admission decisions (RFC 6733 §§5.3, 5.6). c.passiveResponder = false var parsed base.CER _, err := parsed.Parse(in.Message, base.ParseOptions{Role: base.Server, TLS: c.cfg.TLS, Dictionary: c.cfg.Dictionary, LocalApplications: c.caps.LocalApplications}) if err != nil { - return c.rejectCER(in, err) + return c.rejectCER(es, in, err) } if c.cfg.ExpectedHost != "" && !equalIdentity(c.cfg.ExpectedHost, string(parsed.OriginHost)) { - return c.rejectCER(in, errors.New("unexpected remote host")) + return c.rejectCER(es, in, errors.New("unexpected remote host")) } c.caps.CER = copyCER(&parsed) c.caps.ParsedApplications = parsed.Applications() @@ -28,44 +29,55 @@ func (c *Core) candidate(in Input) []Effect { c.cer = copyMessage(in.Message) e := c.effect(ValidatedCER) e.Token = c.candidateToken - e.Snapshot = c.Snapshot() - return []Effect{e} + snapshot := c.Snapshot() + e.Snapshot = &snapshot + if c.cfg.CandidateDeadlineOwnedByPeer { + return append(c.stop(es, HandshakeTimer), e) + } + return append(es, e) } -func (c *Core) activate(in Input) []Effect { +func (c *Core) activate(es []Effect, in Input) []Effect { if c.phase != PreCER || c.candidateToken == 0 || in.Token != c.candidateToken { - return nil + return es } c.candidateToken = 0 + if in.Recovery { + c.wd.State = watchdog.Down + } c.phase = Open c.handshakePending = true c.admission = c.token() - es := c.stop(HandshakeTimer) + es = c.stop(es, HandshakeTimer) e := c.effect(Publish) e.Token = c.admission - e.Snapshot = c.Snapshot() + snapshot := c.Snapshot() + e.Snapshot = &snapshot es = append(es, e) answer, err := base.BuildCEA(c.cer, c.cfg.Local, diam.Success) if err != nil { in.Message = c.cer - return append(es, c.fail(in, diam.ProtocolReplyFailure, diam.Success)...) + in.Err = err + return c.fail(es, in, diam.ProtocolReplyFailure, diam.Success) } - return append(es, c.send(answer, nil, true, false, true, nil, c.cer)...) + return c.send(es, answer, nil, true, false, true, nil, c.cer) } func (c *Core) handshakeReady() Effect { c.callbackReady = true e := c.effect(HandshakeReady) e.Token = c.admission - e.Snapshot = c.Snapshot() + snapshot := c.Snapshot() + e.Snapshot = &snapshot return e } -func (c *Core) cea(in Input) []Effect { +func (c *Core) cea(es []Effect, in Input) []Effect { r := c.controls[diam.CapabilitiesExchange] - if !base.FatalHeaderError(in.Error) && !c.matches(r, in.Message) { - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + fatal := base.FatalHeaderError(in.Error) || c.cfg.FatalCEAErrorBeforeCorrelation && in.Error != nil && in.Error.Fatal + if !fatal && !c.matches(r, in.Message) { + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } if in.Error != nil { c.handshake = newHandshakeResult(in.Error, nil) - return c.fail(in, diam.ProtocolCEARejected, in.Error.ResultCode) + return c.fail(es, in, diam.ProtocolCEARejected, in.Error.ResultCode) } if err := base.ValidateMessage(in.Message); err != nil { c.handshake = newHandshakeResult(err, nil) @@ -73,7 +85,7 @@ func (c *Core) cea(in Input) []Effect { if base.ValidateHeader(in.Message) != nil { kind = diam.ProtocolInvalidHeader } - return c.fail(in, kind, err.ResultCode) + return c.fail(es, in, kind, err.ResultCode) } var parsed base.CEA err := parsed.Parse(in.Message, base.ParseOptions{Role: base.Client, Dictionary: c.cfg.Dictionary, LocalApplications: c.caps.LocalApplications}) @@ -87,16 +99,16 @@ func (c *Core) cea(in Input) []Effect { } else if errors.As(err, &malformed) { code = malformed.ResultCode } - return c.fail(in, diam.ProtocolCEARejected, code) + return c.fail(es, in, diam.ProtocolCEARejected, code) } if c.cfg.ExpectedHost != "" && !equalIdentity(c.cfg.ExpectedHost, string(parsed.OriginHost)) { c.handshake = newHandshakeResult(ErrUnexpectedPeer, &parsed) - return c.fail(in, diam.ProtocolCEARejected, 0) + return c.fail(es, in, diam.ProtocolCEARejected, 0) } - es := []Effect{c.consumed(in.Message)} - es = append(es, c.settle(r)...) - es = append(es, c.stop(HandshakeTimer)...) - es = append(es, c.stop(RetryTimer)...) + es = append(es, c.consumed(in.Message)) + es = c.settle(es, r) + es = c.stop(es, HandshakeTimer) + es = c.stop(es, RetryTimer) c.caps.CEA = copyCEA(&parsed) header := *in.Message.Header c.completedCEA = &header @@ -107,10 +119,11 @@ func (c *Core) cea(in Input) []Effect { c.admission = c.token() e := c.effect(Publish) e.Token = c.admission - e.Snapshot = c.Snapshot() + snapshot := c.Snapshot() + e.Snapshot = &snapshot return append(es, e, c.handshakeReady()) } -func (c *Core) rejectCER(in Input, err error) []Effect { +func (c *Core) rejectCER(es []Effect, in Input, err error) []Effect { code := uint32(diam.UnableToComply) if errors.Is(err, base.ErrNoCommonApplication) { code = diam.NoCommonApplication @@ -120,13 +133,13 @@ func (c *Core) rejectCER(in Input, err error) []Effect { } var me *diam.MessageError if errors.As(err, &me) { - return c.reject(in, diam.ProtocolCERRejected, me) + return c.reject(es, in, diam.ProtocolCERRejected, me) } answer, buildErr := base.BuildCEA(in.Message, c.cfg.Local, code) if buildErr != nil { - return c.failedRejection(in, diam.ProtocolCERRejected, code) + return c.failedRejection(es, in, diam.ProtocolCERRejected, code, buildErr) } c.terminal = true n := Notification{Protocol: diam.ProtocolCERRejected, ResultCode: code, ReplyCode: code, Closing: true, Message: metadata(in.Message), CauseError: copyHandshakeError(err)} - return c.send(answer, nil, true, true, false, &n) + return c.send(es, answer, nil, true, true, false, &n) } diff --git a/diam/internal/protocol/handshake_result.go b/diam/internal/protocol/handshake_result.go index c70f807..4d2e585 100644 --- a/diam/internal/protocol/handshake_result.go +++ b/diam/internal/protocol/handshake_result.go @@ -2,7 +2,9 @@ package protocol import ( "errors" + "fmt" "reflect" + "strings" "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/datatype" @@ -32,11 +34,12 @@ func (r HandshakeResult) CEA() *base.CEA { // Parser errors with mutable evidence are copied on capture and access. Plain // sentinels retain identity; wrapped errors retain errors.Is/errors.As behavior. -func copyHandshakeError(err error) error { +func copyHandshakeError(err error, preserveFormatWrapper ...bool) error { if err == nil { return nil } c := newHandshakeErrorCopy() + c.preserveFormatWrapper = len(preserveFormatWrapper) != 0 && preserveFormatWrapper[0] return c.copyError(err, 0) } @@ -54,6 +57,9 @@ type handshakeErrorCopy struct { ceas map[*base.CEA]*base.CEA remaining int cut uint64 + // Standard formatting wrappers can retain their concrete diagnostic type + // while the wrapped parser evidence remains detached and bounded. + preserveFormatWrapper bool } type handshakeErrorVisit struct { @@ -141,6 +147,15 @@ func (c *handshakeErrorCopy) copyError(err error, depth int) error { return out } cause := c.copyError(e.Unwrap(), depth+1) + // fmt's single-cause wrapper has immutable text and one private error + // field. Rebuild it around the bounded copy to retain its concrete + // diagnostic type without retaining mutable parser evidence. + if c.preserveFormatWrapper && v.Type() == handshakeFmtWrapType && c.cut == cut && cause != nil { + before, after, found := strings.Cut(message(), e.Unwrap().Error()) + if found { + return fmt.Errorf("%s%w%s", before, cause, after) + } + } return handshakeError{message: message(), cause: cause} default: // Standard immutable sentinels retain identity. Other leaf state, @@ -264,6 +279,7 @@ func (c *handshakeErrorCopy) copyCEA(cea *base.CEA) *base.CEA { } var handshakeSentinelType = reflect.TypeOf(errors.New("")) +var handshakeFmtWrapType = reflect.TypeOf(fmt.Errorf("%w", errors.New(""))) func immutableHandshakeError(v reflect.Value) bool { if v.Kind() != reflect.Pointer { diff --git a/diam/internal/protocol/header_fatal_policy_test.go b/diam/internal/protocol/header_fatal_policy_test.go new file mode 100644 index 0000000..7dd9f82 --- /dev/null +++ b/diam/internal/protocol/header_fatal_policy_test.go @@ -0,0 +1,104 @@ +package protocol + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" +) + +// RFC 6733 §§3 and 7.1.3 distinguish framing loss from an invalid base +// command header. Header precedence retains each frontend's established +// observer and transport contract: Manager preserves the original fatal flag; +// StateMachine's replacement header diagnosis is recoverable. +func TestHeaderValidationFatalDispositionPolicy(t *testing.T) { + descriptions := []struct { + name string + code uint32 + fatal, framing, failed bool + }{ + {"missing", diam.MissingAVP, false, false, false}, + {"fatal-missing", diam.MissingAVP, true, false, false}, + {"fatal-protocol", diam.CommandUnsupported, true, false, false}, + {"fatal-invalid-value", diam.InvalidAVPValue, true, false, false}, + {"fatal-avp-length", diam.InvalidAVPLength, true, false, false}, + {"fatal-failed-avp-length", diam.InvalidAVPLength, true, false, true}, + {"fatal-version", diam.UnsupportedVersion, true, true, false}, + {"fatal-message-length", diam.InvalidMessageLength, true, true, false}, + } + for _, manager := range []bool{false, true} { + for _, command := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { + for _, req := range []bool{false, true} { + for _, description := range descriptions { + name := fmt.Sprintf("manager=%t/command=%d/request=%t/%s", manager, command, req, description.name) + t.Run(name, func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.ReaderOwnsMessageErrorClosure = manager + cfg.PreserveDecoderFatalityOnHeaderError = manager + cfg.SeparateHeaderErrorDiagnostics = manager + cfg.CloseCEROnInvalidHeaderBitsOnly = manager + }) + msg := request(t, command) + if !req { + msg = msg.Answer(diam.Success) + } + msg.Header.ApplicationID = 4 + input := receive(msg) + input.ErrorInput = true + input.Error = &diam.MessageError{ResultCode: description.code, Fatal: description.fatal} + if description.failed { + input.Error.FailedAVP = diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2}) + } + effects := c.Handle(input) + var sends int + for _, effect := range effects { + if effect.Kind != Send { + continue + } + sends++ + if !req { + t.Fatal("malformed answer selected an answer") + } + if effect.Message.Header.CommandCode != command || effect.Message.Header.HopByHopID != msg.Header.HopByHopID || effect.Message.Header.EndToEndID != msg.Header.EndToEndID { + t.Fatalf("header rejection changed exchange coordinates: %+v", effect.Message.Header) + } + completion := complete(effect) + completion.ReplyCode = resultCode(t, effect.Message) + effects = append(effects, c.Handle(completion)...) + } + if req && sends != 1 { + t.Fatalf("request selected %d answers, want one", sends) + } + wantClosing := description.framing || manager && description.fatal || req && command == diam.CapabilitiesExchange + wantCode := uint32(diam.InvalidHDRBits) + wantKind := diam.ProtocolInvalidHeader + if description.framing { + wantCode = description.code + wantKind = diam.ProtocolMalformedMessage + } + notice := find(t, effects, Notify).Notification + wantReply, wantReplyCode := diam.ProtocolReplyNone, uint32(0) + if req { + wantReply, wantReplyCode = diam.ProtocolReplyWritten, wantCode + } + if notice.Reply != wantReply || notice.ReplyCode != wantReplyCode { + t.Fatalf("header rejection reply=%v/%d, want %v/%d", notice.Reply, notice.ReplyCode, wantReply, wantReplyCode) + } + if notice.Protocol != wantKind || notice.ResultCode != wantCode || notice.Closing != wantClosing { + t.Fatalf("header/error precedence notification=%+v, want %s/%d closing=%t", notice, wantKind, wantCode, wantClosing) + } + wantClosed := wantClosing + if manager { + wantClosed = req && command == diam.CapabilitiesExchange && !description.framing + } + if got := c.Snapshot().Phase(); (got == Closed) != wantClosed { + t.Fatalf("phase=%v, want closed=%t", got, wantClosed) + } + }) + } + } + } + } +} diff --git a/diam/internal/protocol/initiator_lifecycle_test.go b/diam/internal/protocol/initiator_lifecycle_test.go index 7066b77..5143910 100644 --- a/diam/internal/protocol/initiator_lifecycle_test.go +++ b/diam/internal/protocol/initiator_lifecycle_test.go @@ -48,7 +48,7 @@ func TestInitiatorLifecycleAtCERBoundaries(t *testing.T) { for _, close := range []InputKind{TransportLost, Abort, Retire} { t.Run(fmt.Sprintf("%s/close%d", stage, close), func(t *testing.T) { c := machine(t, func(cfg *Config) { - cfg.Role, cfg.CERTransportLossCompatibility = Initiator, true + cfg.Role, cfg.KeepCERDeadlineAfterTransportLoss = Initiator, true cfg.HandshakeTimeout, cfg.CERInterval = 0, time.Second }) start := c.Handle(event(Start)) @@ -108,7 +108,7 @@ func TestInitiatorLifecycleAtCERBoundaries(t *testing.T) { func TestClientCERWriteErrorEvidence(t *testing.T) { for _, stage := range []WriteStage{WrittenStage, SkippedStage} { t.Run(fmt.Sprint(stage), func(t *testing.T) { - c := machine(t, func(cfg *Config) { cfg.Role, cfg.CERTransportLossCompatibility = Initiator, true }) + c := machine(t, func(cfg *Config) { cfg.Role, cfg.KeepCERDeadlineAfterTransportLoss = Initiator, true }) sent := grant(t, c, c.Handle(event(Start))) in := complete(sent) in.Stage, in.Err, in.Bytes = stage, net.ErrClosed, 0 diff --git a/diam/internal/protocol/local_disconnect_cause_test.go b/diam/internal/protocol/local_disconnect_cause_test.go new file mode 100644 index 0000000..5a7228f --- /dev/null +++ b/diam/internal/protocol/local_disconnect_cause_test.go @@ -0,0 +1,58 @@ +package protocol + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" +) + +func TestLocalDisconnectAllowsOpaqueCause(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, cause := range []uint32{0, 1, 2, 3, ^uint32(0)} { + t.Run(fmt.Sprintf("allow-any=%t/cause=%d", enabled, cause), func(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.AllowAnyLocalDisconnectCause = enabled }) + in := event(Disconnect) + in.Cause = cause + if !enabled && cause > 2 { + wantKinds(t, c.Handle(in), Notify) + if c.Snapshot().Phase() != Open { + t.Fatal("default rejected cause changed connection phase") + } + return + } + reservation := find(t, fence(t, c, in, false), ReserveIDs) + allocated := event(ReserveResult) + allocated.Token = reservation.Token + allocated.Reservation = Reservation{Token: reservation.Token, Hop: 71, End: 72} + sent := find(t, c.Handle(allocated), Send) + value, err := sent.Message.FindAVP(avp.DisconnectCause, 0) + if err != nil || value.Data != datatype.Enumerated(cause) { + t.Fatalf("Disconnect-Cause=%v, %v; want %d", value, err, cause) + } + if sent.Message.Header.CommandCode != diam.DisconnectPeer || c.Snapshot().Phase() != Closing { + t.Fatal("local disconnect did not send DPR in Closing") + } + }) + } + } +} + +func TestOpaqueLocalCauseDoesNotRelaxReceivedDPR(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.AllowAnyLocalDisconnectCause = true }) + dpr := request(t, diam.DisconnectPeer) + value, err := dpr.FindAVP(avp.DisconnectCause, 0) + if err != nil { + t.Fatal(err) + } + value.Data = datatype.Enumerated(3) + answer := find(t, c.Handle(receive(dpr)), Send) + if resultCode(t, answer.Message) != diam.InvalidAVPValue { + t.Fatal("invalid received cause was not rejected with 5004") + } + if c.Snapshot().Phase() != Open { + t.Fatal("invalid received cause changed connection phase") + } +} diff --git a/diam/internal/protocol/matrix_test.go b/diam/internal/protocol/matrix_test.go index 095c1cf..7362b43 100644 --- a/diam/internal/protocol/matrix_test.go +++ b/diam/internal/protocol/matrix_test.go @@ -155,8 +155,8 @@ func next(name string, es ...EffectKind) matrixExpectation { return matrixExpectation{effects: es, next: name} } -// RFC 6733 §§5.3–5.6 and #108: all 16 input kinds in every phase, plus -// validated candidate, hook, CEA-write and handshake-callback substates. The +// Every input kind in every phase, plus validated candidate, hook, CEA-write +// and handshake-callback substates. The // literal effect rows are an external contract, not calls to production guards. func TestEveryPhaseInputPair(t *testing.T) { rows := map[string]map[InputKind]matrixExpectation{ @@ -197,7 +197,7 @@ func TestEveryPhaseInputPair(t *testing.T) { "closed": {}, "retired": {}, } for _, name := range []string{"pre", "candidate", "hook", "allocating", "waiting", "cea-write", "admission", "open", "closing-fence", "closing", "closed", "retired"} { - for k := Start; k <= StopSupervision; k++ { + for k := Start; k <= BeginCER; k++ { t.Run(fmt.Sprintf("%s/input-%d", name, k), func(t *testing.T) { f := matrixSetup(t, name) in := matrixInput(t, name, k, f) @@ -293,12 +293,13 @@ func matrixPayloads(t testing.TB, name string, k InputKind, f matrixFixture, bef case CancelSend: want.Token = f.send.Token case Hold: - want.Input = in + want.Input = &in case Admit: want.Message = in.Message want.Ownership = in.Ownership case ValidatedCER, Publish, HandshakeReady: - want.Snapshot = f.core.Snapshot() + snapshot := f.core.Snapshot() + want.Snapshot = &snapshot want.Token = want.Snapshot.Admission() if e.Kind == ValidatedCER { want.Token = want.Snapshot.Candidate() @@ -320,7 +321,7 @@ func matrixPayloads(t testing.TB, name string, k InputKind, f matrixFixture, bef case Activate: message, err = base.BuildCEA(cer(t), f.core.cfg.Local, diam.Success) case Unhandled: - want.Notification = Notification{Protocol: diam.ProtocolUnsupportedCommand, ResultCode: diam.CommandUnsupported, Message: metadata(in.Message)} + want.Notification = Notification{Protocol: diam.ProtocolUnsupportedCommand, ResultCode: diam.CommandUnsupported, Message: metadata(in.Message), Diagnostic: "unhandled"} message, err = base.BuildErrorAnswer(in.Message, f.core.cfg.Local, diam.CommandUnsupported, nil, true) default: message, err = base.BuildDWA(in.Message, f.core.cfg.Local) diff --git a/diam/internal/protocol/message_error_callback_test.go b/diam/internal/protocol/message_error_callback_test.go new file mode 100644 index 0000000..c09f6f3 --- /dev/null +++ b/diam/internal/protocol/message_error_callback_test.go @@ -0,0 +1,105 @@ +package protocol + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +func TestBoundHeaderlessCallbackHasNoProtocolEffect(t *testing.T) { + for _, closing := range []bool{false, true} { + for _, empty := range []bool{false, true} { + t.Run(fmt.Sprintf("closing=%t/empty-message=%t", closing, empty), func(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.IgnoreBoundHeaderlessInput = true }) + if closing { + c.Handle(event(Disconnect)) + } + phase, timer := c.Phase(), c.Snapshot().Timer(ClosingTimer) + in := event(Receive) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + if empty { + in.Message = new(diam.Message) + } + for _, e := range c.Handle(in) { + if e.Kind != Discard { + t.Fatalf("headerless bound callback selected work: %+v", e) + } + } + if c.Phase() != phase || c.Snapshot().Timer(ClosingTimer) != timer { + t.Fatal("headerless callback changed lifecycle/deadline") + } + }) + } + } +} + +func TestInitialNonCERCallbackDiagnosticPrecedence(t *testing.T) { + for _, errorInput := range []bool{false, true} { + t.Run(fmt.Sprintf("callback=%t", errorInput), func(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.InitialNonCERHeaderOnError = true }) + c.Handle(event(Start)) + in := receive(request(t, diam.DeviceWatchdog)) + in.Message.Header.CommandFlags |= diam.ProxiableFlag + in.ErrorInput = errorInput + wantCode := uint32(0) + if errorInput { + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength} + wantCode = diam.InvalidHDRBits + } + n := find(t, c.Handle(in), Notify).Notification + if n.Protocol != diam.ProtocolHandshakeState || n.ResultCode != wantCode || !n.Closing || n.Reply != diam.ProtocolReplyNone || c.Phase() != Closed { + t.Fatalf("first non-CER disposition changed: %+v phase=%v", n, c.Phase()) + } + if errorInput { + cause, ok := n.CauseError.(*diam.MessageError) + if !ok || cause.ResultCode != wantCode { + t.Fatalf("header diagnostic lost its typed cause: %T %v", n.CauseError, n.CauseError) + } + } + }) + } + for _, empty := range []bool{false, true} { + t.Run(fmt.Sprintf("headerless/empty-message=%t", empty), func(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.IgnoreBoundHeaderlessInput = true }) + c.Handle(event(Start)) + in := event(Receive) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + if empty { + in.Message = new(diam.Message) + } + n := find(t, c.Handle(in), Notify).Notification + if n.Protocol != diam.ProtocolHandshakeState || n.ResultCode != diam.InvalidAVPLength || !n.Closing || n.CauseError == nil { + t.Fatalf("headerless admission lost its diagnostic: %+v", n) + } + }) + } +} + +func TestFatalCEACallbackPrecedesCorrelation(t *testing.T) { + for _, fatal := range []bool{false, true} { + t.Run(fmt.Sprintf("fatal=%t", fatal), func(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Role = Initiator; cfg.FatalCEAErrorBeforeCorrelation = true }) + sent := grant(t, c, c.Handle(event(Start))) + in := receive(sent.Message.Answer(diam.Success)) + in.Message.Header.HopByHopID++ + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: fatal} + var n Notification + for _, e := range c.Handle(in) { + if e.Kind == Notify && e.Notification.Protocol != "" { + n = e.Notification + } + } + if fatal { + if n.Protocol != diam.ProtocolCEARejected || n.ResultCode != diam.InvalidAVPLength || !n.Closing || c.Phase() != Closed { + t.Fatalf("fatal handshake callback was treated as stray: %+v phase=%v", n, c.Phase()) + } + } else if n.Protocol != diam.ProtocolUnexpectedMessage || n.ResultCode != 0 || n.Closing || c.Phase() != WaitCEA { + t.Fatalf("recoverable stray changed handshake ownership: %+v phase=%v", n, c.Phase()) + } + }) + } +} diff --git a/diam/internal/protocol/operation_storage.go b/diam/internal/protocol/operation_storage.go new file mode 100644 index 0000000..4ae53a4 --- /dev/null +++ b/diam/internal/protocol/operation_storage.go @@ -0,0 +1,11 @@ +package protocol + +// recycleOperation retains at most one completed record per connection. An +// outstanding writer keeps its own record until its terminal result, even when +// its answer has already been consumed. +func (c *Core) recycleOperation(op *operation) { + *op = operation{} + if c.spareOperation == nil { + c.spareOperation = op + } +} diff --git a/diam/internal/protocol/operation_storage_test.go b/diam/internal/protocol/operation_storage_test.go new file mode 100644 index 0000000..bd6b872 --- /dev/null +++ b/diam/internal/protocol/operation_storage_test.go @@ -0,0 +1,96 @@ +package protocol + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +func TestCompletedAnswerOperationsReuseStorage(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.QueueWrites = true }) + c.phase = Open + answer := diam.NewMessage(diam.DeviceWatchdog, 0, 0, 31, 32, c.cfg.Dictionary) + buffer := make([]Effect, 0, 1) + allocations := testing.AllocsPerRun(100, func() { + effects := c.send(buffer, answer, nil, true, false, false, nil) + sent := effects[0] + queued := event(WriteResult) + queued.Token = sent.Token + queued.Stage = QueueStage + c.HandleInto(queued, buffer[:0]) + if !c.CompleteQueuedReply(complete(sent)) { + t.Fatal("answer completion retained an obligation") + } + }) + if allocations != 0 { + t.Fatalf("completed answer operation allocated %.0f records; want reused storage", allocations) + } +} + +func TestOperationReusePreservesOutstandingWriters(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.QueueWrites = true }) + c.phase = Open + answer := diam.NewMessage(diam.DeviceWatchdog, 0, 0, 31, 32, c.cfg.Dictionary) + first := c.send(nil, answer, nil, true, false, false, nil)[0] + second := c.send(nil, answer, nil, true, false, false, &Notification{Protocol: diam.ProtocolUnsupportedCommand})[0] + if c.operations[first.Token] == c.operations[second.Token] { + t.Fatal("selected writers share an operation record") + } + queued := event(WriteResult) + queued.Stage = QueueStage + queued.Token = first.Token + c.Handle(queued) + if !c.CompleteQueuedReply(complete(first)) { + t.Fatal("first answer did not complete") + } + third := c.send(nil, answer, nil, false, true, true, nil)[0] + if c.operations[second.Token].notice == nil || c.operations[third.Token].notice != nil || !c.operations[third.Token].handshake || !c.operations[third.Token].closeAfter { + t.Fatal("reused record changed another writer or retained reply metadata") + } + if len(c.Handle(complete(first))) != 0 || len(c.operations) != 2 { + t.Fatal("duplicate completion consumed a later writer") + } + // Ordinary Handle completion releases storage after copying its notice. + c.Handle(complete(second)) + if c.operations[third.Token] == nil { + t.Fatal("later reply lost its outstanding completion") + } + fourth := c.send(nil, answer, nil, true, false, false, nil)[0] + if c.operations[third.Token] == c.operations[fourth.Token] || !c.operations[third.Token].handshake || !c.operations[third.Token].closeAfter { + t.Fatal("reused operation storage overwrote an outstanding writer") + } + if c.operations[fourth.Token].notice != nil || c.operations[fourth.Token].handshake || c.operations[fourth.Token].closeAfter { + t.Fatal("completed record retained prior notification or terminal obligations") + } +} + +func TestCompletedOperationsReleaseProtocolPayloads(t *testing.T) { + for _, stage := range []WriteStage{QueueStage, WrittenStage, SkippedStage} { + t.Run(map[WriteStage]string{QueueStage: "queue rejected", WrittenStage: "written", SkippedStage: "skipped control"}[stage], func(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.QueueWrites = true }) + c.phase = Open + answer := diam.NewMessage(diam.DeviceWatchdog, 0, 0, 31, 32, c.cfg.Dictionary) + var selected *control + if stage == SkippedStage { + selected = &control{command: diam.DeviceWatchdog} + } + sent := c.send(nil, answer, selected, true, false, false, &Notification{Protocol: diam.ProtocolUnsupportedCommand})[0] + op := c.operations[sent.Token] + in := complete(sent) + in.Stage = stage + if stage == QueueStage { + in.Err = diam.ErrConnectionRetired + } + effects := c.Handle(in) + if c.operations[sent.Token] != nil || *op != (operation{}) { + t.Fatal("terminal writer retained protocol payloads") + } + if stage == WrittenStage { + notice := find(t, effects, Notify).Notification + if notice.Protocol != diam.ProtocolUnsupportedCommand || notice.Reply != diam.ProtocolReplyWritten { + t.Fatal("operation disposal changed its completed notice") + } + } + }) + } +} diff --git a/diam/internal/protocol/queued_reply.go b/diam/internal/protocol/queued_reply.go new file mode 100644 index 0000000..55d9fb2 --- /dev/null +++ b/diam/internal/protocol/queued_reply.go @@ -0,0 +1,27 @@ +package protocol + +// CompleteQueuedReply consumes only successful, effect-free answer completion. +// The caller serializes it with Handle, after queue feedback has committed. Any +// result with an outstanding protocol obligation must instead enter Handle. +// Request controls, handshake and terminal replies retain their ordered +// completion effects; ordinary queued answers release only their operation +// record. The transport writer still owns its publication pin. +func (c *Core) CompleteQueuedReply(in Input) bool { + if in.Connection != c.cfg.Connection || in.Kind != WriteResult || in.Stage != WrittenStage || in.Err != nil { + return false + } + op := c.operations[in.Token] + if op == nil || !op.queued || !op.reported || op.control != nil || op.closeAfter || op.handshake { + return false + } + expected := in.ExpectedBytes + if expected == 0 { + expected = op.length + } + if in.Bytes != expected { + return false + } + delete(c.operations, in.Token) + c.recycleOperation(op) + return true +} diff --git a/diam/internal/protocol/queued_reply_test.go b/diam/internal/protocol/queued_reply_test.go new file mode 100644 index 0000000..7c28c0c --- /dev/null +++ b/diam/internal/protocol/queued_reply_test.go @@ -0,0 +1,89 @@ +package protocol + +import ( + "errors" + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +type queuedReplyCompleter interface{ CompleteQueuedReply(Input) bool } + +func TestQueuedReplyCompletionPreservesOtherObligations(t *testing.T) { + for _, name := range []string{"success", "post-hook length", "short", "long", "connection", "kind", "queue stage", "skipped stage", "error", "not queued", "not reported", "close after", "handshake", "control", "unknown token", "closing", "closed"} { + t.Run(name, func(t *testing.T) { + c := machine(t) + c.phase = Open + op := &operation{length: 100, queued: true, reported: true, required: true, notice: &Notification{Protocol: diam.ProtocolUnsupportedCommand}} + c.operations[42] = op + in := Input{Kind: WriteResult, Connection: c.cfg.Connection, Token: 42, Stage: WrittenStage, Bytes: 100} + want := false + switch name { + case "success": + want = true + case "post-hook length": + in.ExpectedBytes = 120 + in.Bytes = 120 + want = true + case "short": + in.Bytes = 99 + case "long": + in.Bytes = 101 + case "connection": + in.Connection.Generation++ + case "kind": + in.Kind = Receive + case "queue stage": + in.Stage = QueueStage + case "skipped stage": + in.Stage = SkippedStage + case "error": + in.Err = errors.New("write failed") + case "not queued": + op.queued = false + case "not reported": + op.reported = false + case "close after": + op.closeAfter = true + case "handshake": + op.handshake = true + case "control": + op.control = &control{command: diam.DisconnectPeer, logical: true, writers: 1} + case "unknown token": + in.Token++ + case "closing": + c.phase = Closing + want = true + case "closed": + c.phase = Closed + want = true + } + completer, ok := any(c).(queuedReplyCompleter) + if !ok { + t.Fatal("core cannot complete an effect-free queued reply") + } + saved := *op + reference := *c + copied := *op + reference.operations = map[uint64]*operation{42: &copied} + if got := completer.CompleteQueuedReply(in); got != want { + t.Fatalf("completion=%t want=%t", got, want) + } + if !want { + if c.operations[42] != op || *op != saved || c.phase != reference.phase { + t.Fatal("fallback changed pending write state") + } + return + } + if effects := reference.Handle(in); len(effects) != 0 { + t.Fatalf("successful completion discarded effects: %+v", effects) + } + if len(c.operations) != 0 || c.phase != reference.phase { + t.Fatal("successful completion retained operation or changed phase") + } + if completer.CompleteQueuedReply(in) { + t.Fatal("completed token consumed twice") + } + }) + } +} diff --git a/diam/internal/protocol/receive.go b/diam/internal/protocol/receive.go index 851b754..c270db1 100644 --- a/diam/internal/protocol/receive.go +++ b/diam/internal/protocol/receive.go @@ -7,44 +7,55 @@ import ( "github.com/gomaja/go-diameter/diam/internal/base" ) -func (c *Core) receive(in Input) (es []Effect) { +func (c *Core) receive(es []Effect, in Input) (out []Effect) { if c.passiveResponder && in.ErrorInput { if in.TransportClosed { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } // Nil message reports have no retained external admission. Preserve // their framing-failure obligation even while Closing. if in.Message == nil && c.phase == Closing { - return c.fail(in, diam.ProtocolMalformedMessage, 0) + return c.fail(es, in, diam.ProtocolMalformedMessage, 0) } } if c.terminal || c.phase == Closing && !c.closingAllowed(in) { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } if c.hookToken != 0 || c.phase == Open && !c.ready { e := c.effect(Hold) - e.Input = in + saved := in + e.Input = &saved e.Input.Message = copyMessage(in.Message) - return []Effect{e} + return append(es, e) } m := in.Message if m == nil || m.Header == nil { + if c.cfg.IgnoreBoundHeaderlessInput { + if c.phase != PreCER || c.cfg.Role == Initiator || c.candidateToken != 0 { + return es + } + var code uint32 + if in.Error != nil { + code = in.Error.ResultCode + } + return c.fail(es, in, diam.ProtocolHandshakeState, code) + } if c.passiveResponder && c.passivePresent && in.ErrorInput && m != nil { - return c.externalMessageError(in, diam.ProtocolMalformedMessage, in.Error != nil && in.Error.Fatal) + return c.externalMessageError(es, in, diam.ProtocolMalformedMessage, in.Error != nil && in.Error.Fatal) } - return c.fail(in, diam.ProtocolMalformedMessage, 0) + return c.fail(es, in, diam.ProtocolMalformedMessage, 0) } isCERRequest := m.Header.CommandCode == diam.CapabilitiesExchange && m.Header.CommandFlags&diam.RequestFlag != 0 isCER := isCERRequest && m.Header.ApplicationID == 0 if c.phase == WaitCEA || c.phase == PreCER && c.cfg.Role == Initiator { if m.Header.CommandCode == diam.CapabilitiesExchange && m.Header.ApplicationID == 0 && m.Header.CommandFlags&diam.RequestFlag == 0 { - return c.cea(in) + return c.cea(es, in) } var code uint32 if in.Error != nil { code = in.Error.ResultCode } - return c.fail(in, diam.ProtocolHandshakeState, code) + return c.fail(es, in, diam.ProtocolHandshakeState, code) } // Removing external metadata restores admission gating during Closing too, // including decoder-error inputs eligible for that phase. @@ -53,38 +64,46 @@ func (c *Core) receive(in Input) (es []Effect) { var code uint32 if in.Error != nil { code = in.Error.ResultCode - } else if headerErr := base.ValidateHeader(m); headerErr != nil { + } else if headerErr := base.ValidateHeader(m); headerErr != nil && !c.cfg.InitialNonCERHeaderOnError { code = headerErr.ResultCode } - return c.fail(in, diam.ProtocolHandshakeState, code) + if c.cfg.InitialNonCERHeaderOnError && in.ErrorInput && c.candidateToken == 0 && !base.FatalHeaderError(in.Error) { + if headerErr := base.ValidateHeader(m); headerErr != nil { + in.Error = headerErr + code = headerErr.ResultCode + } + } + return c.fail(es, in, diam.ProtocolHandshakeState, code) } - activity := c.activity(in) + start := len(es) + es = c.activity(es, in) releaseDispatch := false defer func() { - es = append(activity, es...) - if releaseDispatch && len(es) != 0 { - es[0].ReleaseDispatch = true + if releaseDispatch && len(out) > start { + out[start].ReleaseDispatch = true } }() - // Repeated CER disposition precedes optional body revalidation. - if c.phase == Open && isCER && !base.FatalHeaderError(in.Error) && (!c.passiveResponder || !in.ErrorInput || in.Error == nil || !in.Error.Fatal) { + // Repeated CER disposition precedes optional body revalidation; fatal + // decoder reports retain the frontend's rejection/close obligation. + fatalCER := in.Error != nil && in.Error.Fatal && (c.cfg.RejectFatalRepeatedCER || c.passiveResponder && in.ErrorInput) + if c.phase == Open && isCER && !base.FatalHeaderError(in.Error) && !fatalCER { if c.passiveResponder && c.passiveCER && in.Error == nil && !in.ErrorInput { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } if in.Error == nil && base.ValidateMessage(m) == nil && base.ValidateRequest(m, c.caps.LocalApplications, c.cfg.ValidateRequests, c.cfg.RejectUnknownMandatoryAVPs) == nil && c.bound(m, true) { - activity = append(activity, c.validActivity()...) + es = c.validActivity(es) } if c.cfg.Hooks && in.Error == nil && !in.ErrorInput { - return c.pauseHook(in) + return c.pauseHook(es, in) } - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } // Client's completed CER exchange consumes no further decoded base CEA, // including one with invalid semantics. Keep its correlation diagnostic // ahead of validation; receipt activity above still receives no health credit. // Decoder failures retain the separate message-error path below. - if !in.ErrorInput && c.phase == Open && c.cfg.Role == Initiator && m.Header.CommandCode == diam.CapabilitiesExchange && m.Header.ApplicationID == 0 && m.Header.CommandFlags&diam.RequestFlag == 0 && in.Error == nil { - es := c.discard(in, diam.ProtocolUnexpectedMessage, 0) + if !c.cfg.ValidateCompletedCEA && !in.ErrorInput && c.phase == Open && c.cfg.Role == Initiator && m.Header.CommandCode == diam.CapabilitiesExchange && m.Header.ApplicationID == 0 && m.Header.CommandFlags&diam.RequestFlag == 0 && in.Error == nil { + es = c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) es[len(es)-1].Notification.Diagnostic = "cea-correlation" if h := c.completedCEA; h != nil && h.HopByHopID == m.Header.HopByHopID && h.EndToEndID == m.Header.EndToEndID { es[len(es)-1].Notification.Diagnostic = "duplicate-cea" @@ -93,37 +112,41 @@ func (c *Core) receive(in Input) (es []Effect) { } originalFatal := in.Error != nil && in.Error.Fatal if err := base.ValidateHeader(m); err != nil && !base.FatalHeaderError(in.Error) { + // Fatality is independent of which diagnosis wins header precedence. + if c.cfg.PreserveDecoderFatalityOnHeaderError { + err.Fatal = originalFatal + } in.Error = err } if in.Error != nil { - return c.messageError(in, originalFatal) + return c.messageError(es, in, originalFatal) } if in.ErrorInput { - return c.discard(in, diam.ProtocolMalformedMessage, 0) + return c.discard(es, in, diam.ProtocolMalformedMessage, 0) } if c.phase == Open && c.cfg.Watchdog && m.Header.CommandCode == diam.DeviceWatchdog && m.Header.CommandFlags&diam.RequestFlag == 0 { releaseDispatch = true - return c.dwa(in) + return c.dwa(es, in) } if err := base.ValidateMessage(m); err != nil { if m.Header.CommandFlags&diam.RequestFlag == 0 { in.Error = err - es := c.discard(in, base.AnswerErrorKind(m), err.ResultCode) + es = c.discard(es, in, base.AnswerErrorKind(m), err.ResultCode) if c.passiveResponder { es[len(es)-1].Notification.Diagnostic = "external-invalid-base" } return es } - return c.reject(in, diam.ProtocolInvalidRequest, err) + return c.reject(es, in, diam.ProtocolInvalidRequest, err) } if rejection := base.ValidateRequest(m, c.caps.LocalApplications, c.cfg.ValidateRequests, c.cfg.RejectUnknownMandatoryAVPs); rejection != nil { - return c.requestError(in, rejection) + return c.requestError(es, in, rejection) } if c.phase == PreCER { if c.cfg.Hooks { - return c.pauseHook(in) + return c.pauseHook(es, in) } - return c.candidate(in) + return c.candidate(es, in) } baseDPR := m.Header.ApplicationID == 0 && m.Header.CommandCode == diam.DisconnectPeer && m.Header.CommandFlags&diam.RequestFlag != 0 releaseDispatch = m.Header.CommandCode != diam.CapabilitiesExchange && !baseDPR @@ -132,46 +155,50 @@ func (c *Core) receive(in Input) (es []Effect) { case diam.DeviceWatchdog: if m.Header.CommandFlags&diam.RequestFlag != 0 { if c.cfg.Hooks { - return c.pauseHook(in) + return c.pauseHook(es, in) } - return c.dwr(in) + return c.dwr(es, in) } if !in.HasHandler { - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } e := c.effect(Admit) e.Message = m - return []Effect{e} + return append(es, e) case diam.DisconnectPeer: if m.Header.CommandFlags&diam.RequestFlag != 0 { - return c.dpr(in) + return c.dpr(es, in) } - return c.dpa(in) + return c.dpa(es, in) case diam.CapabilitiesExchange: if !in.HasHandler { - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } e := c.effect(Admit) e.Message = m - return []Effect{e} + return append(es, e) } } + if c.cfg.Watchdog && c.wd.State == "REOPEN" { + return c.discard(es, in, "", 0) + } if in.Unhandled && m.Header.CommandFlags&diam.RequestFlag != 0 { if base.SupportsApplication(c.caps.LocalApplications, m.Header.ApplicationID) { - activity = append(activity, c.validActivity()...) + es = c.validActivity(es) } - return c.unhandled(in) + return c.unhandled(es, in) } - activity = append(activity, c.validActivity()...) + es = c.validActivity(es) if c.cfg.Watchdog && c.wd.State == "REOPEN" { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } e := c.effect(Admit) e.Message = m e.Ownership = in.Ownership - return []Effect{e} + return append(es, e) } -func (c *Core) pauseHook(in Input) []Effect { + +func (c *Core) pauseHook(es []Effect, in Input) []Effect { token := c.token() saved := in saved.Message = copyMessage(in.Message) @@ -185,69 +212,79 @@ func (c *Core) pauseHook(in Input) []Effect { e := c.effect(RunHook) e.Token = token e.Message = copyMessage(in.Message) - return []Effect{e} + return append(es, e) } -func (c *Core) dwr(in Input) []Effect { + +func (c *Core) dwr(es []Effect, in Input) []Effect { var parsed base.DWR err := parsed.Parse(in.Message) if err == nil && !c.bound(in.Message, false) { err = errors.New("DWR Origin-Host does not match the admitted peer (RFC 6733 §6.3)") } if err != nil { - return c.discardCause(in, diam.ProtocolInvalidDWR, err) + return c.discardCause(es, in, diam.ProtocolInvalidDWR, err) } answer, err := base.BuildDWA(in.Message, c.cfg.Local) if err != nil { - return c.fail(in, diam.ProtocolReplyFailure, diam.Success) + return c.fail(es, in, diam.ProtocolReplyFailure, diam.Success) } - reply := c.send(answer, nil, true, false, false, nil, in.Message) - reply[0].AnswerHook = true - return append(c.validActivity(), reply...) + es = c.validActivity(es) + es = c.send(es, answer, nil, true, false, false, nil, in.Message) + es[len(es)-1].AnswerHook = true + return es } -func (c *Core) discardCause(in Input, kind diam.ProtocolErrorKind, err error) []Effect { - es := c.discard(in, kind, 0) +func (c *Core) discardCause(es []Effect, in Input, kind diam.ProtocolErrorKind, err error) []Effect { + es = c.discard(es, in, kind, 0) es[len(es)-1].Notification.CauseError = copyHandshakeError(err) return es } -func (c *Core) messageError(in Input, originalFatal bool) []Effect { + +func (c *Core) messageError(es []Effect, in Input, originalFatal bool) []Effect { m, me := in.Message, in.Error kind := diam.ProtocolMalformedMessage if base.ValidateHeader(m) != nil && !base.FatalHeaderError(me) { kind = diam.ProtocolInvalidHeader } - if c.passiveResponder && in.ErrorInput { - return c.externalMessageError(in, kind, originalFatal) + if c.passiveResponder && in.ErrorInput || c.cfg.ReaderOwnsMessageErrorClosure && (c.phase == Open || c.phase == Closing) { + return c.externalMessageError(es, in, kind, originalFatal) } if c.phase == Closing { - return c.discard(in, kind, me.ResultCode) + return c.discard(es, in, kind, me.ResultCode) } if m.Header.CommandFlags&diam.RequestFlag == 0 { if me.Fatal { - return c.fail(in, kind, me.ResultCode) + return c.fail(es, in, kind, me.ResultCode) } - return c.discard(in, kind, me.ResultCode) + return c.discard(es, in, kind, me.ResultCode) } if rejection := base.ValidateRequest(m, c.caps.LocalApplications, c.cfg.ValidateRequests, c.cfg.RejectUnknownMandatoryAVPs, me); rejection != nil && !base.IsCommand(m) && !me.Fatal { - return c.requestError(in, rejection) + return c.requestError(es, in, rejection) } - return c.reject(in, kind, me) + return c.reject(es, in, kind, me) } // externalMessageError retains the frontend's error-reporting contract for // externally admitted peers. Closing in a report and closing the transport are // separate: framing ownership remains with the frontend that supplied the error. -func (c *Core) externalMessageError(in Input, kind diam.ProtocolErrorKind, originalFatal bool) []Effect { +func (c *Core) externalMessageError(es []Effect, in Input, kind diam.ProtocolErrorKind, originalFatal bool) []Effect { me := in.Error n := Notification{Protocol: kind, Message: metadata(in.Message), Closing: originalFatal, Diagnostic: "external-message-error"} if me != nil { n.ResultCode, n.CauseError = me.ResultCode, copyHandshakeError(me) } - if c.phase == Closing { + headerDiagnosis := c.cfg.SeparateHeaderErrorDiagnostics && kind == diam.ProtocolInvalidHeader + if headerDiagnosis { + n.Diagnostic = "header-error" + if c.phase == Closing && in.Message.Header.CommandFlags&diam.RequestFlag != 0 { + n.Protocol = diam.ProtocolMalformedMessage + } + } + if c.phase == Closing && !c.cfg.RetainClosingErrorFatality { n.Closing = false } if c.phase == Closing || in.Message.Header == nil || in.Message.Header.CommandFlags&diam.RequestFlag == 0 { - return []Effect{c.effect(Discard), c.notify(n)} + return append(es, c.effect(Discard), c.notify(n)) } var failed []*diam.AVP if me.FailedAVP != nil { @@ -260,50 +297,64 @@ func (c *Core) externalMessageError(in Input, kind diam.ProtocolErrorKind, origi } answer, err := base.BuildErrorAnswer(in.Message, c.cfg.Local, me.ResultCode, failed, me.ResultCode >= 3000 && me.ResultCode < 4000) if err != nil { - es := c.failedRejection(in, n.Protocol, me.ResultCode) + es = c.failedRejection(es, in, n.Protocol, me.ResultCode, err) failure := &es[len(es)-1].Notification failure.CauseError = copyHandshakeError(err) failure.Diagnostic = "external-message-error" return es } terminal := in.Message.Header.CommandCode == diam.CapabilitiesExchange && base.ValidateHeader(in.Message) != nil + if c.cfg.CloseCEROnInvalidHeaderBitsOnly { + terminal = in.Message.Header.CommandCode == diam.CapabilitiesExchange && me.ResultCode == diam.InvalidHDRBits + } if terminal { c.terminal, n.Closing, n.Diagnostic = true, true, "external-reject-cer" + if headerDiagnosis { + n.Diagnostic = "reject-cer-header" + } } - return c.send(answer, nil, true, terminal, false, &n) + return c.send(es, answer, nil, true, terminal, false, &n) } -func (c *Core) requestError(in Input, r *base.RequestError) []Effect { +func (c *Core) requestError(es []Effect, in Input, r *base.RequestError, diagnostic ...string) []Effect { + if c.phase == Closing { + return c.discard(es, in, r.Kind, r.Code) + } answer, err := base.BuildErrorAnswer(in.Message, c.cfg.Local, r.Code, r.Failed, r.Code >= 3000 && r.Code < 4000) if err != nil { - return c.failedRejection(in, r.Kind, r.Code) - } - if c.phase == Closing { - return c.discard(in, r.Kind, r.Code) + es = c.failedRejection(es, in, r.Kind, r.Code, err) + if len(diagnostic) != 0 { + es[len(es)-1].Notification.Diagnostic = diagnostic[0] + } + return es } terminal := c.phase == PreCER if terminal { c.terminal = true } n := Notification{Protocol: r.Kind, ResultCode: r.Code, Closing: terminal, Message: metadata(in.Message)} + if len(diagnostic) != 0 { + n.Diagnostic = diagnostic[0] + } if terminal && in.Message.Header.CommandCode == diam.CapabilitiesExchange { n.Diagnostic = "reject-cer" } - return c.send(answer, nil, true, terminal, false, &n) + return c.send(es, answer, nil, true, terminal, false, &n) } -func (c *Core) unhandled(in Input) []Effect { + +func (c *Core) unhandled(es []Effect, in Input) []Effect { if c.phase != Open || !c.ready { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } if in.Message == nil || in.Message.Header == nil { - return c.discard(in, "", 0) + return c.discard(es, in, "", 0) } if in.Message.Header.CommandFlags&diam.RequestFlag == 0 { - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } kind, code := diam.ProtocolUnsupportedCommand, uint32(diam.CommandUnsupported) if !base.SupportsApplication(c.caps.LocalApplications, in.Message.Header.ApplicationID) { kind, code = diam.ProtocolUnsupportedApplication, diam.ApplicationUnsupported } - return c.requestError(in, &base.RequestError{Kind: kind, Code: code}) + return c.requestError(es, in, &base.RequestError{Kind: kind, Code: code}, "unhandled") } diff --git a/diam/internal/protocol/rejection_evidence_test.go b/diam/internal/protocol/rejection_evidence_test.go new file mode 100644 index 0000000..fab9eca --- /dev/null +++ b/diam/internal/protocol/rejection_evidence_test.go @@ -0,0 +1,72 @@ +package protocol + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/dict" + "github.com/gomaja/go-diameter/diam/internal/base" +) + +func TestRejectedMessageRetainsOriginalEvidence(t *testing.T) { + for _, closing := range []bool{false, true} { + t.Run(fmt.Sprintf("closing=%t", closing), func(t *testing.T) { + c := opened(t) + if closing { + c.Handle(event(Disconnect)) + } + before := c.Snapshot().Timer(ClosingTimer) + empty, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + in := receive(diam.NewMessage(diam.DisconnectPeer, diam.RequestFlag, 0, 1, 2, empty)) + failed := diam.NewAVP(1, 0, 0, datatype.Unknown{1, 2}) + original := &diam.MessageError{ResultCode: diam.InvalidAVPLength, FailedAVP: failed} + notice := find(t, c.reject(nil, in, diam.ProtocolInvalidRequest, original), Notify).Notification + copied, ok := notice.CauseError.(*diam.MessageError) + if !ok || copied == original || copied.ResultCode != original.ResultCode || copied.FailedAVP == failed { + t.Fatalf("rejection lost detached typed evidence: %T %#v", notice.CauseError, notice.CauseError) + } + failed.Data.(datatype.Unknown)[0] = 9 + if copied.FailedAVP.Data.(datatype.Unknown)[0] != 1 { + t.Fatal("rejection retained mutable failed AVP") + } + if closing { + if c.Phase() != Closing || c.Snapshot().Timer(ClosingTimer) != before || notice.Reply != diam.ProtocolReplyNone { + t.Fatalf("Closing rejection changed lifecycle or selected a reply: %+v", notice) + } + } else if notice.Reply != diam.ProtocolReplyFailed || notice.ReplyError == nil || c.Phase() != Closed { + t.Fatalf("builder failure lost its distinct outcome: %+v", notice) + } + }) + } +} + +func TestClosingRequestRejectionSkipsAnswerConstruction(t *testing.T) { + c := opened(t) + c.Handle(event(Disconnect)) + before := c.Snapshot().Timer(ClosingTimer) + empty, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + in := receive(diam.NewMessage(diam.DisconnectPeer, diam.RequestFlag, 0, 1, 2, empty)) + es := c.requestError(nil, in, &base.RequestError{Kind: diam.ProtocolUnsupportedAVP, Code: diam.AVPUnsupported}) + var notice Notification + for _, e := range es { + if e.Kind == Notify && e.Notification.Protocol != "" { + notice = e.Notification + } + } + if c.Phase() != Closing || c.Snapshot().Timer(ClosingTimer) != before || notice.Protocol != diam.ProtocolUnsupportedAVP || notice.ResultCode != diam.AVPUnsupported || notice.Reply != diam.ProtocolReplyNone { + t.Fatalf("Closing rejection attempted a failing answer build: phase=%v notice=%+v", c.Phase(), notice) + } + for _, e := range es { + if e.Kind != Notify && e.Kind != Discard { + t.Fatalf("Closing rejection selected work: %v", e.Kind) + } + } +} diff --git a/diam/internal/protocol/reopen_unhandled_test.go b/diam/internal/protocol/reopen_unhandled_test.go new file mode 100644 index 0000000..79158cc --- /dev/null +++ b/diam/internal/protocol/reopen_unhandled_test.go @@ -0,0 +1,20 @@ +package protocol + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +func TestReopenDiscardsUnhandledTraffic(t *testing.T) { + c := supervised(t, true) + in := receive(diam.NewRequest(999, 4, c.cfg.Dictionary)) + in.Unhandled = true + es := c.Handle(in) + for _, e := range es { + if e.Kind == Send || e.Kind == Admit { + t.Fatalf("REOPEN selected application work: %+v", e) + } + } + find(t, es, Discard) +} diff --git a/diam/internal/protocol/repeated_cer_fatal_test.go b/diam/internal/protocol/repeated_cer_fatal_test.go new file mode 100644 index 0000000..ae1870f --- /dev/null +++ b/diam/internal/protocol/repeated_cer_fatal_test.go @@ -0,0 +1,68 @@ +package protocol + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +// Manager retains the required fatal-error answer while leaving transport +// closure to the decoder's caller. StateMachine has its own repeat policy. +func TestRepeatedCERFatalDecoderDisposition(t *testing.T) { + for _, manager := range []bool{false, true} { + for _, errorInput := range []bool{false, true} { + for _, fatal := range []bool{false, true} { + t.Run(fmt.Sprintf("manager=%t/error-input=%t/fatal=%t", manager, errorInput, fatal), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.RejectFatalRepeatedCER = manager + cfg.ReaderOwnsMessageErrorClosure = manager + cfg.QueueWrites = true + }) + input := receive(cer(t)) + input.ErrorInput = errorInput + input.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: fatal} + effects := c.Handle(input) + if !manager || !fatal { + notice := find(t, effects, Notify).Notification + if notice.Protocol != diam.ProtocolUnexpectedMessage || notice.ResultCode != 0 || notice.Closing || notice.Reply != diam.ProtocolReplyNone || c.Snapshot().Phase() != Open { + t.Fatalf("recoverable/frontend repeated CER disposition changed: %+v phase=%v", notice, c.Snapshot().Phase()) + } + for _, effect := range effects { + if effect.Kind == Send || effect.Kind == Close { + t.Fatalf("ignored repeated CER selected terminal work: %+v", effect) + } + } + return + } + sent := find(t, effects, Send) + if !sent.Required || sent.CloseAfter || resultCode(t, sent.Message) != diam.InvalidAVPLength || sent.Message.Header.CommandCode != diam.CapabilitiesExchange || sent.Message.Header.HopByHopID != input.Message.Header.HopByHopID || sent.Message.Header.EndToEndID != input.Message.Header.EndToEndID { + t.Fatalf("fatal repeated CER required answer changed: %+v", sent) + } + queued := event(WriteResult) + queued.Token = sent.Token + queued.Stage = QueueStage + queued.ReplyCode = diam.InvalidAVPLength + notice := find(t, c.Handle(queued), Notify).Notification + if notice.Protocol != diam.ProtocolMalformedMessage || notice.ResultCode != diam.InvalidAVPLength || !notice.Closing || notice.Reply != diam.ProtocolReplyQueued || notice.ReplyCode != diam.InvalidAVPLength { + t.Fatalf("fatal repeated CER queue report changed: %+v", notice) + } + if c.Snapshot().Phase() != Open { + t.Fatal("fatal repeated CER closed before its required write") + } + written := complete(sent) + written.ReplyCode = diam.InvalidAVPLength + effects = c.Handle(written) + if c.Snapshot().Phase() != Open { + t.Fatal("fatal callback took transport closure from its caller") + } + for _, effect := range effects { + if effect.Kind == Notify || effect.Kind == Close { + t.Fatalf("successful reply duplicated its report or closed the transport: %+v", effect) + } + } + }) + } + } + } +} diff --git a/diam/internal/protocol/reply_build_cause_test.go b/diam/internal/protocol/reply_build_cause_test.go new file mode 100644 index 0000000..6e1a46c --- /dev/null +++ b/diam/internal/protocol/reply_build_cause_test.go @@ -0,0 +1,90 @@ +package protocol + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/dict" +) + +func TestRequiredReplyBuildFailureRetainsCause(t *testing.T) { + c := machine(t, func(cfg *Config) { cfg.Local.HostIPAddresses = []datatype.Address{{}} }) + c.Handle(event(Start)) + candidate := find(t, c.Handle(receive(cer(t))), ValidatedCER) + in := event(Activate) + in.Token = candidate.Token + var n Notification + for _, e := range c.Handle(in) { + if e.Kind == Notify && e.Notification.Protocol == diam.ProtocolReplyFailure { + n = e.Notification + } + } + if n.CauseError == nil { + t.Fatal("required reply builder failure lost its original error") + } +} + +func TestRejectionBuildFailureRetainsTypedBuilderCause(t *testing.T) { + for _, open := range []bool{false, true} { + t.Run(fmt.Sprintf("open=%t", open), func(t *testing.T) { + cfg := func(cfg *Config) { + cfg.PreserveReplyErrorFormatWrapper = true + cfg.ReaderOwnsMessageErrorClosure = true + cfg.RejectFatalRepeatedCER = true + } + c := machine(t, cfg) + if open { + c = opened(t, cfg) + } else { + c.Handle(event(Start)) + } + dictionary, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + in := receive(diam.NewMessage(diam.CapabilitiesExchange, diam.RequestFlag, 0, 1, 2, dictionary)) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + var n Notification + for _, e := range c.Handle(in) { + if e.Kind == Notify && e.Notification.Protocol != "" { + n = e.Notification + } + } + if n.Reply != diam.ProtocolReplyFailed || n.ReplyCode != diam.InvalidAVPLength || !n.Closing || n.ReplyError == nil { + t.Fatalf("lost rejected exchange or builder failure: %+v", n) + } + if reflect.TypeOf(n.ReplyError) != handshakeFmtWrapType || !strings.Contains(n.ReplyError.Error(), "cannot form valid Diameter error answer") { + t.Fatalf("builder error changed: %T %v", n.ReplyError, n.ReplyError) + } + }) + } +} + +func TestCopiedStandardWrapperRetainsTypeAndDetachesEvidence(t *testing.T) { + failed := diam.NewAVP(1, 0, 0, datatype.Unknown{1, 2}) + validation := &diam.ValidationError{ResultCode: diam.InvalidAVPLength, FailedAVP: failed} + original := fmt.Errorf("before: %w: after", validation) + if defaultCopy := copyHandshakeError(original); reflect.TypeOf(defaultCopy) == reflect.TypeOf(original) || defaultCopy.Error() != original.Error() { + t.Fatalf("default wrapper diagnostic changed: %T %v", defaultCopy, defaultCopy) + } + copied := copyHandshakeError(original, true) + if reflect.TypeOf(copied) != reflect.TypeOf(original) || copied.Error() != original.Error() { + t.Fatalf("copied wrapper changed type/text: %T %v", copied, copied) + } + var evidence *diam.ValidationError + if !errors.As(copied, &evidence) || evidence == validation || evidence.FailedAVP == failed { + t.Fatal("wrapper retained mutable parser evidence") + } + for range 100 { + original = fmt.Errorf("wrapped: %w", original) + } + if bounded := copyHandshakeError(original, true); !strings.Contains(bounded.Error(), "traversal limit") { + t.Fatal("standard wrappers bypassed the error traversal bound") + } +} diff --git a/diam/internal/protocol/types.go b/diam/internal/protocol/types.go index 1d2fddd..c1f3161 100644 --- a/diam/internal/protocol/types.go +++ b/diam/internal/protocol/types.go @@ -15,8 +15,10 @@ import ( ) // Connection distinguishes separate connections even when their generations tie. -type Connection struct{ Instance, Generation uint64 } -type Role uint8 +type ( + Connection struct{ Instance, Generation uint64 } + Role uint8 +) const ( Responder Role = iota + 1 @@ -50,16 +52,70 @@ type Config struct { Watchdog bool WatchdogInterval time.Duration Recovery bool + // A peer actor owns the election deadline after a CER validates. The + // connection's pre-CER deadline ends when it hands that candidate over. + CandidateDeadlineOwnedByPeer bool + // IgnoreBoundHeaderlessInput discards nil/headerless input except when a + // responder awaits its first CER. That initial input is a handshake-state + // failure retaining the supplied decoder result. + IgnoreBoundHeaderlessInput bool + // InitialNonCERHeaderOnError derives an initial non-CER header diagnosis + // only for non-framing decoder reports. A decoded non-CER reports only the + // handshake-state violation, without deriving a header result code. + InitialNonCERHeaderOnError bool + // RejectFatalRepeatedCER preserves the required error reply for a repeated + // CER carrying a fatal decoder error, ahead of ordinary repeat disposition. + RejectFatalRepeatedCER bool + // ValidateCompletedCEA retains grammar/header diagnostics for decoded CEAs + // after an initiator handshake, rather than classifying duplicates first. + ValidateCompletedCEA bool + // PreserveDecoderFatalityOnHeaderError keeps the input decoder's fatal flag + // when base-header validation replaces its error diagnosis. + PreserveDecoderFatalityOnHeaderError bool + // ReaderOwnsMessageErrorClosure reports message errors on admitted inputs + // without taking transport closure from the reader. Required error replies + // still run; the CER header-rejection rule can select close-after-reply. + ReaderOwnsMessageErrorClosure bool + // RetainClosingErrorFatality keeps original fatality in message-error + // reports during Closing, independently of transport closure ownership. + RetainClosingErrorFatality bool + // SeparateHeaderErrorDiagnostics selects distinct base-header diagnostics; + // a rejected request during Closing is reported as malformed input. + SeparateHeaderErrorDiagnostics bool + // CloseCEROnInvalidHeaderBitsOnly restricts message-error CER closure to + // InvalidHDRBits; other decoder errors leave closure with their caller. + CloseCEROnInvalidHeaderBitsOnly bool + // FatalBodyErrorKeepsWatchdogDeadline excludes fatal, valid-header decoder + // callbacks from Tw activity. Recoverable and invalid-header input retain + // their ordinary decoded-activity policy. + FatalBodyErrorKeepsWatchdogDeadline bool + // FatalCEAErrorBeforeCorrelation rejects a fatal CEA decoder report before + // checking whether its identifiers match the pending CER. + FatalCEAErrorBeforeCorrelation bool + // DWAOwnershipBeforeSemantics classifies an unowned DWA after grammar + // validation but before interpreting its identity or Result-Code. + DWAOwnershipBeforeSemantics bool + // DWAIdentityBeforeResult diagnoses an unbound unsuccessful DWA by identity + // before interpreting its Result-Code as a rejection of the local probe. + DWAIdentityBeforeResult bool + // PreserveReplyErrorFormatWrapper retains the concrete standard fmt wrapper + // type around a bounded, detached error-answer builder failure. + PreserveReplyErrorFormatWrapper bool // StateMachine's synchronous Disconnect may start after a peer DPR. Its // caller deadline is independent of the peer-initiated Closing deadline. LocalDisconnectInClosing bool - // StateMachine applies its shared parser's post-OnDWR discard/result - // contract. Wire grammar is validated before selecting the hook (§5.5.1). - DWRHookCompatibility bool - // Client keeps its CER response window after EOF. With retries left, + // AllowAnyLocalDisconnectCause preserves Manager.Close's existing opaque + // uint32 input. RFC 6733 section 5.4.3 defines only values 0-2; this local + // API compatibility option never relaxes validation of received DPRs. + AllowAnyLocalDisconnectCause bool + // SkipDWRGrammarAfterHook applies the control parser's discard/result rules + // to the hook output without repeating pre-hook wire-grammar validation. + SkipDWRGrammarAfterHook bool + // KeepCERDeadlineAfterTransportLoss retains a pending CER's response window + // after EOF. With retries left, // the next write's actual error completes the handshake; otherwise the // response timer reports timeout. This does not govern accepted peers. - CERTransportLossCompatibility bool + KeepCERDeadlineAfterTransportLoss bool } // Reservation identifies a lease in the caller's shared application/control ID @@ -96,6 +152,8 @@ const ( TransportLost Retire StopSupervision + // BeginCER releases a startup deferred by its peer actor. + BeginCER ) type WriteStage uint8 @@ -126,6 +184,12 @@ const ( // WriteResult must include the post-hook ExpectedBytes and ReplyCode. Every Send // needs a terminal result, including skipped and queue-rejected writes. type Input struct { + // DeferCER commits initiator startup without sending or arming a response + // deadline until the peer actor accepts its endpoint dial completion. + DeferCER bool + // Recovery seeds accepted activation from peer history, which cannot be + // selected until a validated CER identifies the peer. + Recovery bool // Resolved outside the core mutex before the first CER is built. RefreshLocalAddresses bool LocalAddresses []datatype.Address @@ -206,6 +270,7 @@ const ( // holds the core's mutation lock or prevents timer/lifecycle Handle calls. type Notification struct { CauseError error // detached diagnostic cause; public events retain kind/result metadata + ReplyError error // detached error-answer construction failure Diagnostic string // frontend log detail selected with the protocol decision Kind NoticeKind Protocol diam.ProtocolErrorKind @@ -245,10 +310,12 @@ type Effect struct { Notification Notification Timer TimerKind Deadline time.Time - Snapshot Snapshot - Input Input - Ownership uint64 - Cause uint32 + // Snapshot is present on ValidatedCER, Publish, and HandshakeReady. + Snapshot *Snapshot + // Input is present on Hold and belongs to the effect recipient. + Input *Input + Ownership uint64 + Cause uint32 } // Capabilities keeps local advertisement separate from the remote offer and @@ -315,4 +382,5 @@ func (s Snapshot) Timer(k TimerKind) TimerSnapshot { } return s.timers[k] } + func (s Snapshot) Controls() []ControlSnapshot { return append([]ControlSnapshot(nil), s.controls...) } diff --git a/diam/internal/protocol/unowned_control_test.go b/diam/internal/protocol/unowned_control_test.go new file mode 100644 index 0000000..b0e1225 --- /dev/null +++ b/diam/internal/protocol/unowned_control_test.go @@ -0,0 +1,165 @@ +package protocol + +import ( + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" +) + +// A generation that has never issued this control command retains the +// frontend's unsolicited-answer grammar diagnostics (RFC 6733 §§3, 5.4–5.5). +func TestInitialUnownedControlAnswerRetainsBodyValidation(t *testing.T) { + for _, command := range []uint32{diam.DeviceWatchdog, diam.DisconnectPeer} { + t.Run(controlTestName(command), func(t *testing.T) { + c := opened(t, func(cfg *Config) { cfg.DWAOwnershipBeforeSemantics = true }) + msg := diam.NewMessage(command, 0, 0, 91, 92, c.cfg.Dictionary) + n := find(t, c.Handle(receive(msg)), Notify).Notification + want := diam.ProtocolInvalidDWA + if command == diam.DisconnectPeer { + want = diam.ProtocolInvalidDPA + } + if n.Protocol != want || n.ResultCode != diam.MissingAVP || n.Closing { + t.Fatalf("initial unsolicited grammar diagnosis changed: %+v", n) + } + in := receive(msg) + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength} + n = find(t, c.Handle(in), Notify).Notification + if n.Protocol != diam.ProtocolMalformedMessage || n.ResultCode != diam.InvalidAVPLength { + t.Fatalf("initial decoder diagnosis changed: %+v", n) + } + }) + } +} + +// Unmatched answers keep grammar diagnostics without completing or +// semantically rejecting the local exchange. +func TestUnownedControlAnswerRetainsGrammarDiagnostics(t *testing.T) { + for _, command := range []uint32{diam.DeviceWatchdog, diam.DisconnectPeer} { + t.Run(controlTestName(command), func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.DWAOwnershipBeforeSemantics = true + cfg.Watchdog = true + cfg.WatchdogInterval = 6 * time.Second + }) + if command == diam.DeviceWatchdog { + probe(t, c) + } else { + sent := grant(t, c, fence(t, c, event(Disconnect), false)) + c.Handle(complete(sent)) + } + msg := diam.NewMessage(command, 0, 0, 91, 92, c.cfg.Dictionary) + in := receive(msg) + n := find(t, c.Handle(in), Notify).Notification + want := diam.ProtocolInvalidDWA + if command == diam.DisconnectPeer { + want = diam.ProtocolInvalidDPA + } + if n.Protocol != want || n.ResultCode != diam.MissingAVP { + t.Fatalf("unowned answer lost grammar diagnosis: %+v", n) + } + in.ErrorInput = true + in.Error = &diam.MessageError{ResultCode: diam.InvalidAVPLength} + n = find(t, c.Handle(in), Notify).Notification + if n.Protocol != diam.ProtocolMalformedMessage || n.ResultCode != diam.InvalidAVPLength { + t.Fatalf("unowned decoded answer semantically rejected: %+v", n) + } + }) + } +} + +func TestUnownedControlGrammarAfterCompletedExchange(t *testing.T) { + for _, variant := range []string{"malformed-duplicate", "malformed-stray", "other-command", "new-generation"} { + t.Run(variant, func(t *testing.T) { + configure := func(cfg *Config) { + cfg.DWAOwnershipBeforeSemantics = true + cfg.Watchdog = true + cfg.WatchdogInterval = 6 * time.Second + } + c := opened(t, configure) + sent := probe(t, c) + c.Handle(receive(dwa(t, sent, diam.Success))) + if len(c.Snapshot().Controls()) != 0 || c.Snapshot().Watchdog().Pending { + t.Fatal("valid DWA failed to finish exchange") + } + msg := diam.NewMessage(diam.DeviceWatchdog, 0, 0, sent.Message.Header.HopByHopID, sent.Message.Header.EndToEndID, c.cfg.Dictionary) + want, code := diam.ProtocolInvalidDWA, uint32(diam.MissingAVP) + switch variant { + case "malformed-stray": + msg.Header.HopByHopID++ + case "other-command": + msg.Header.CommandCode = diam.DisconnectPeer + want, code = diam.ProtocolInvalidDPA, diam.MissingAVP + case "new-generation": + c = opened(t, configure) + want, code = diam.ProtocolInvalidDWA, diam.MissingAVP + } + notice := find(t, c.Handle(receive(msg)), Notify).Notification + if notice.Protocol != want || notice.ResultCode != code || notice.Closing { + t.Fatalf("completed exchange changed grammar diagnosis: %+v want %s/%d", notice, want, code) + } + if c.Snapshot().Phase() != Open || c.Snapshot().Watchdog().Pending { + t.Fatal("unowned answer reopened an exchange or closed the generation") + } + }) + } +} + +func TestRejectedDWAIdentityPrecedesResult(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.DWAIdentityBeforeResult = true + cfg.Watchdog = true + cfg.WatchdogInterval = 6 * time.Second + }) + sent := probe(t, c) + msg := dwa(t, sent, diam.UnableToComply) + for _, field := range msg.AVP { + if field.Code == avp.OriginHost { + field.Data = datatype.DiameterIdentity("other.example") + } + } + n := find(t, c.Handle(receive(msg)), Notify).Notification + if n.Protocol != diam.ProtocolInvalidDWA || n.ResultCode != 0 { + t.Fatalf("unbound result was interpreted: %+v", n) + } + if c.Snapshot().Controls()[0].Answered { + t.Fatal("unbound rejection consumed native ownership") + } +} + +func TestUnownedDWAResultCannotRejectExchange(t *testing.T) { + for _, state := range []string{"initial", "current", "completed"} { + t.Run(state, func(t *testing.T) { + c := opened(t, func(cfg *Config) { + cfg.DWAOwnershipBeforeSemantics = true + cfg.Watchdog = true + cfg.WatchdogInterval = 6 * time.Second + }) + sent := Effect{Message: request(t, diam.DeviceWatchdog)} + if state != "initial" { + sent = probe(t, c) + } + if state == "completed" { + c.Handle(receive(dwa(t, sent, diam.Success))) + } + msg := dwa(t, sent, diam.UnableToComply) + if state == "current" { + msg.Header.HopByHopID++ + } + n := find(t, c.Handle(receive(msg)), Notify).Notification + if n.Protocol != diam.ProtocolUnexpectedMessage || n.ResultCode != 0 { + t.Fatalf("unowned result rejected an exchange: %+v", n) + } + }) + } +} + +func controlTestName(command uint32) string { + if command == diam.DeviceWatchdog { + return "DWA" + } + return "DPA" +} diff --git a/diam/internal/protocol/watchdog.go b/diam/internal/protocol/watchdog.go index 5e812de..4900f6b 100644 --- a/diam/internal/protocol/watchdog.go +++ b/diam/internal/protocol/watchdog.go @@ -10,125 +10,143 @@ import ( "github.com/gomaja/go-diameter/diam/internal/watchdog" ) -func (c *Core) watchdogArm(at time.Time, interval time.Duration) []Effect { +func (c *Core) watchdogArm(es []Effect, at time.Time, interval time.Duration) []Effect { if interval <= 0 { interval = c.cfg.WatchdogInterval } if interval <= 0 { - return c.stop(WatchdogTimer) + return c.stop(es, WatchdogTimer) } // RFC 3539 §3.4.1: stop the previous Tw before arming its replacement. - // Build the ordered pair together rather than growing a one-effect slice. previous := c.timers[WatchdogTimer] arm := c.effect(ArmTimer) arm.Timer, arm.Token, arm.Deadline = WatchdogTimer, c.token(), at.Add(interval) c.timers[WatchdogTimer] = TimerSnapshot{arm.Token, arm.Deadline} if previous.Token == 0 { - return []Effect{arm} + return append(es, arm) } stop := c.effect(StopTimer) stop.Timer, stop.Token = WatchdogTimer, previous.Token - return []Effect{stop, arm} + return append(es, stop, arm) } -func (c *Core) watchdogUp(in Input) []Effect { +func (c *Core) watchdogUp(es []Effect, in Input) []Effect { if !c.cfg.Watchdog { - return nil + return es } next, actions := c.wd.Step(watchdog.ConnectionUp) c.wd = next if actions.Send { c.wdNext = &next c.wd.Pending = false - return c.reserve(diam.DeviceWatchdog) + return c.reserve(es, diam.DeviceWatchdog) } if actions.Reset { - return c.watchdogArm(in.At, in.Interval) + return c.watchdogArm(es, in.At, in.Interval) } - return nil + return es } -func (c *Core) watchdogPublished(in Input) []Effect { +func (c *Core) watchdogPublished(es []Effect, in Input) []Effect { if c.phase != Open || c.wdNext == nil { - return nil + return es } c.wd = *c.wdNext c.wdNext = nil - es := c.watchdogArm(in.At, in.Interval) + es = c.watchdogArm(es, in.At, in.Interval) return append(es, c.notify(Notification{Kind: WatchdogNotice, Watchdog: c.wd.State})) } // RFC 3539 §3.4.1 [2], Appendix A: any eligible decoded activity resets Tw; // #108 withholds health credit from invalid traffic and Pending credit from // everything except a validated, correlated, successful DWA. -func (c *Core) activity(in Input) []Effect { +func (c *Core) activity(es []Effect, in Input) []Effect { if c.phase != Open || !c.cfg.Watchdog || base.FatalHeaderError(in.Error) { - return nil + return es + } + // Fatal body callbacks can retain the existing deadline. Invalid base + // headers still take the decoded-header activity path. + if c.cfg.FatalBodyErrorKeepsWatchdogDeadline && in.ErrorInput && in.Error != nil && in.Error.Fatal && base.ValidateHeader(in.Message) == nil { + return es } - return c.watchdogArm(in.At, in.Interval) + return c.watchdogArm(es, in.At, in.Interval) } -func (c *Core) validActivity() []Effect { +func (c *Core) validActivity(es []Effect) []Effect { if c.phase != Open || !c.cfg.Watchdog { - return nil + return es } next, actions := c.wd.Step(watchdog.Receive) c.wd = next if actions.Failback { - return []Effect{c.notify(Notification{Kind: WatchdogNotice, Watchdog: next.State})} + return append(es, c.notify(Notification{Kind: WatchdogNotice, Watchdog: next.State})) } - return nil + return es } -func (c *Core) watchdogExpired(in Input) []Effect { +func (c *Core) watchdogExpired(es []Effect, in Input) []Effect { if c.phase != Open || !c.cfg.Watchdog { - return nil + return es } if in.AfterReceive > c.receivedSequence { saved := in c.deferredExpiry = &saved - return nil + return es } // A selected probe is not Pending until publication. Activity may have // armed another Tw meanwhile; do not allocate a second probe or overwrite // the first reservation (RFC 3539 Appendix A, #108 publication boundary). if c.wdNext != nil { - return c.watchdogArm(in.At, in.Interval) + return c.watchdogArm(es, in.At, in.Interval) } next, actions := c.wd.Step(watchdog.Expired) if actions.Close { - return c.fail(in, diam.ProtocolWatchdogTimeout, 0) + return c.fail(es, in, diam.ProtocolWatchdogTimeout, 0) } - es := c.stop(WatchdogTimer) + es = c.stop(es, WatchdogTimer) if actions.Send { c.wdNext = &next - return append(es, c.reserve(diam.DeviceWatchdog)...) + return c.reserve(es, diam.DeviceWatchdog) } c.wd = next if actions.Failover { es = append(es, c.notify(Notification{Kind: WatchdogNotice, Watchdog: next.State})) } if actions.Reset { - es = append(es, c.watchdogArm(in.At, in.Interval)...) + es = c.watchdogArm(es, in.At, in.Interval) } return es } -func (c *Core) dwa(in Input) []Effect { +func (c *Core) dwa(es []Effect, in Input) []Effect { m := in.Message if err := base.ValidateMessage(m); err != nil { in.Error = err - return c.discard(in, diam.ProtocolInvalidDWA, err.ResultCode) + return c.discard(es, in, diam.ProtocolInvalidDWA, err.ResultCode) + } + // Grammar diagnostics precede ownership classification as library policy. + // RFC 6733 §§3, 6.2.1: only a correlated answer owns the local exchange. + if c.cfg.DWAOwnershipBeforeSemantics && !c.matches(c.controls[diam.DeviceWatchdog], m) { + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } var parsed base.DWA if err := parsed.Parse(m); err != nil { - return c.discardCause(in, diam.ProtocolInvalidDWA, err) + return c.discardCause(es, in, diam.ProtocolInvalidDWA, err) } r := c.controls[diam.DeviceWatchdog] + if c.cfg.DWAIdentityBeforeResult && parsed.ResultCode != diam.Success && !c.bound(m, true) { + var peer base.DWR + _ = peer.Parse(m) + err := errors.New("DWA Origin-Realm mismatch") + if !equalIdentity(string(peer.OriginHost), c.caps.Host) { + err = errors.New("DWA Origin-Host mismatch") + } + return c.discardCause(es, in, diam.ProtocolInvalidDWA, err) + } if parsed.ResultCode != diam.Success { - var es []Effect + if c.matches(r, m) && c.bound(m, true) { - es = c.consumeControl(r, m) + es = c.consumeControl(es, r, m) } - rejection := c.discard(in, diam.ProtocolRejectedDWA, parsed.ResultCode) - rejection[len(rejection)-1].Notification.CauseError = fmt.Errorf("DWA Result-Code %d", parsed.ResultCode) - return append(es, rejection...) + es = c.discard(es, in, diam.ProtocolRejectedDWA, parsed.ResultCode) + es[len(es)-1].Notification.CauseError = fmt.Errorf("DWA Result-Code %d", parsed.ResultCode) + return es } if !c.bound(m, true) || m.Header.CommandFlags&diam.ErrorFlag != 0 { var identity base.DWR @@ -143,10 +161,10 @@ func (c *Core) dwa(in Input) []Effect { err = errors.New("DWA Origin-Realm mismatch") } } - return c.discardCause(in, diam.ProtocolInvalidDWA, err) + return c.discardCause(es, in, diam.ProtocolInvalidDWA, err) } if !c.matches(r, m) { - return c.discard(in, diam.ProtocolUnexpectedMessage, 0) + return c.discard(es, in, diam.ProtocolUnexpectedMessage, 0) } current := c.wd // A fast answer can precede the writer's return. Apply its credit to the @@ -157,8 +175,8 @@ func (c *Core) dwa(in Input) []Effect { } next, actions := current.Step(watchdog.ReceiveDWA) c.wd = next - es := c.consumeControl(r, m) - es = append(es, c.settle(r)...) + es = c.consumeControl(es, r, m) + es = c.settle(es, r) es = append(es, c.notify(Notification{Kind: WatchdogNotice, Watchdog: next.State, Diagnostic: "answer"})) if actions.Failback { es = append(es, c.notify(Notification{Kind: WatchdogNotice, Watchdog: next.State})) @@ -166,12 +184,12 @@ func (c *Core) dwa(in Input) []Effect { return es } -func (c *Core) consumeControl(r *control, m *diam.Message) []Effect { +func (c *Core) consumeControl(es []Effect, r *control, m *diam.Message) []Effect { if r.answered { - return nil + return es } r.answered = true - return []Effect{c.consumed(m)} + return append(es, c.consumed(m)) } func (c *Core) consumed(m *diam.Message) Effect { diff --git a/diam/internal/protocol/watchdog_timer_pair_test.go b/diam/internal/protocol/watchdog_timer_pair_test.go index 70ba5a6..6177230 100644 --- a/diam/internal/protocol/watchdog_timer_pair_test.go +++ b/diam/internal/protocol/watchdog_timer_pair_test.go @@ -12,16 +12,16 @@ func TestWatchdogArmPreservesEffectsAndTokens(t *testing.T) { original := machine(t) original.cfg.WatchdogInterval = configured if active { - original.arm(WatchdogTimer, epoch, time.Second) + original.arm(nil, WatchdogTimer, epoch, time.Second) } candidate := *original effective := interval if effective <= 0 { effective = configured } - want := original.stop(WatchdogTimer) - want = append(want, original.arm(WatchdogTimer, epoch, effective)...) - got := candidate.watchdogArm(epoch, interval) + want := original.stop(nil, WatchdogTimer) + want = append(want, original.arm(nil, WatchdogTimer, epoch, effective)...) + got := candidate.watchdogArm(nil, epoch, interval) equal := (got == nil) == (want == nil) && len(got) == len(want) for i := 0; equal && i < len(got); i++ { a, b := got[i], want[i] @@ -38,7 +38,7 @@ func TestWatchdogArmPreservesEffectsAndTokens(t *testing.T) { func TestWatchdogTimerReplacementAllocatesOneVector(t *testing.T) { c := machine(t) var effects []Effect - allocations := testing.AllocsPerRun(100, func() { effects = c.watchdogArm(epoch, time.Second) }) + allocations := testing.AllocsPerRun(100, func() { effects = c.watchdogArm(nil, epoch, time.Second) }) if len(effects) != 2 || effects[0].Kind != StopTimer || effects[1].Kind != ArmTimer { t.Fatalf("replacement effects=%+v", effects) } diff --git a/diam/peer/actor.go b/diam/peer/actor.go index 6024baa..23f1e7f 100644 --- a/diam/peer/actor.go +++ b/diam/peer/actor.go @@ -12,16 +12,15 @@ import ( "time" "github.com/gomaja/go-diameter/diam" - "github.com/gomaja/go-diameter/diam/avp" - "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/dict" - "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm" - "github.com/gomaja/go-diameter/diam/sm/smparser" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) type event struct { + // testHook observes actor-owned state at an event boundary; nil in use. + testHook func() receivedAt time.Time receiveSequence uint64 afterReceive uint64 @@ -36,6 +35,8 @@ type event struct { cause sm.DisconnectCause discovery *discoveryCommand leaseState *discoveryState + input protocol.Input + replyDelivery *protocolDelivery } const ( @@ -43,6 +44,7 @@ const ( discoveryStop psmEvent = "discovery-stop" discoveryExpire psmEvent = "discovery-expire" discoveryRefresh psmEvent = "discovery-refresh" + coreEvent psmEvent = "core" wireEvent psmEvent = "wire" dialDone psmEvent = "dial-done" connGone psmEvent = "conn-gone" @@ -157,21 +159,10 @@ func (a *actor) run() { case cmd := <-control: a.retireResponder(cmd) case <-leaseStop: - a.handleDiscovery(event{kind: discoveryStop}) + a.handleEvent(event{kind: discoveryStop}) leaseStop = nil case e := <-a.events: - d := e.delivery - if d == nil { - d = newProtocolDelivery() - } - a.delivery = d - a.handle(e) - a.delivery = nil - d.finish() - if e.delivery == nil && len(d.notifications) > 0 { - a.m.wg.Add(1) - go func() { defer a.m.wg.Done(); d.deliver(a.m) }() - } + a.handleEvent(e) } } if (a.m.isClosing() || a.discovery.Load() != nil && a.ownStop) && a.state == Closed { @@ -195,6 +186,25 @@ func (a *actor) run() { } } } + +// A writer can report failure after an event accepted its reply without any +// admission notices. Finalize that delivery too, with observers off the actor. +func (a *actor) handleEvent(e event) { + d := e.delivery + if d == nil { + d = newProtocolDelivery() + } + a.delivery = d + d.priorReply = e.replyDelivery + a.handle(e) + a.delivery = nil + d.finish() + if e.delivery == nil { + a.m.wg.Add(1) + go func() { defer a.m.wg.Done(); d.deliver(a.m) }() + } +} + func (m *Manager) isClosing() bool { m.mu.RLock(); v := m.closing; m.mu.RUnlock(); return v } func (a *actor) stopTimer() { a.token++ @@ -240,6 +250,10 @@ func (a *actor) failWithLog(level slog.Level, text string, err error) { a.lostConnection(err) } func (a *actor) handle(e event) { + if e.testHook != nil { + e.testHook() + return + } defer func() { if e.s != nil && e.receiveSequence != 0 { e.s.receivedSequence = e.receiveSequence @@ -264,21 +278,16 @@ func (a *actor) handle(e event) { a.onDial(e) return } - if e.s != nil && e.s.isRetired() { + if e.kind == coreEvent { + if e.s != nil { + e.s.applyProtocol(e.input, a.delivery) + } return } - if e.kind == watchdogTimeout { - if e.token == a.wdToken && a.active != nil && !a.active.isRetired() && e.gen == a.active.gen { - // RFC 3539 §3.4.1, Appendix A: messages already admitted at - // expiry commit their receive/DWA decisions before that expiry. - if e.s != nil && e.s.receivedSequence < e.afterReceive { - a.deferredWatchdog = &e - return - } - a.watchdogTick() - } + if e.s != nil && e.s.isRetired() { return } + if e.kind == reconnectTimeout { if e.token == a.tcToken { a.tcTimer = nil @@ -311,6 +320,9 @@ func (a *actor) handle(e event) { return } if e.kind == connGone { + if e.s != nil && e.s.core != nil { + e.s.applyProtocol(protocol.Input{Kind: protocol.TransportLost}, a.delivery) + } a.onGone(e.s) a.m.failoverSession(e.s) return @@ -458,6 +470,10 @@ func (a *actor) onDial(e event) { } return } + if e.s.c.Closed() || e.s.protocolSnapshot().Phase() >= protocol.Closed { + a.failLocal("send CER", net.ErrClosed) + return + } a.i = e.s a.step(iAck, e.s, nil) if a.state == WaitReturns { @@ -482,7 +498,8 @@ func (a *actor) onCER(e event) { } if err := a.admitDiscovery(e.s, e.meta); err != nil { a.signalReady(err) - a.m.rejectCER(e.s, e.msg, diam.UnknownPeer, err, a.delivery) + a.m.report(e.s, e.msg, err) + e.s.applyProtocol(protocol.Input{Kind: protocol.RejectCandidate, Token: e.s.candidate, ResultCode: diam.UnknownPeer, Message: e.msg}, a.delivery) return } if a.state != Closed && a.state != WaitConnAck && a.state != WaitICEA && a.state != WaitConnAckElect && a.state != WaitReturns && a.state != IOpen && a.state != ROpen { @@ -528,288 +545,20 @@ func (a *actor) elect() { } func (a *actor) onWire(e event) { s := e.s - msg := e.msg - if s == nil || msg == nil || msg.Header == nil { + if s == nil || s.isRetired() { return } - if (s != a.i && s != a.r) || s.gen == 0 { - if msg.Header.CommandFlags&diam.RequestFlag == 0 && msg.Header.ApplicationID != 0 { + if s != a.i && s != a.r || s.gen == 0 { + msg := e.msg + if msg != nil && msg.Header != nil && msg.Header.CommandFlags&diam.RequestFlag == 0 && msg.Header.ApplicationID != 0 { a.m.report(s, msg, errors.New("peer: answer on retired connection generation (RFC 6733 §6.2.1)")) a.protocolError(s.c, msg, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0) } return - } // RFC 6733 §5.6: ignore retired candidate generations. - isI := s == a.i - cmd := msg.Header.CommandCode - req := msg.Header.CommandFlags&diam.RequestFlag != 0 - if a.state == WaitICEA || a.state == WaitReturns { - isCEA := isI && cmd == diam.CapabilitiesExchange && !req && msg.Header.ApplicationID == 0 - // RFC 6733 §§3, 5.3, 6.2.1: correlate recognizable CEA headers - // before rejecting a recoverable body error. A stray answer owns - // neither admission nor its deadline; fatal framing still rejects. - if isCEA && (e.messageErr == nil || !e.messageErr.Fatal) && - (s.cerHop != msg.Header.HopByHopID || s.cerEnd != msg.Header.EndToEndID) { - a.m.report(s, msg, errors.New("peer: CEA identifiers mismatch")) - a.protocolError(s.c, msg, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0) - return - } - if isCEA && e.messageErr == nil { - if err := base.ValidateMessage(msg); err != nil { - a.fail(fmt.Errorf("peer: invalid CEA: %w", err)) - kind := diam.ProtocolCEARejected - if base.ValidateHeader(msg) != nil { - kind = diam.ProtocolInvalidHeader - } - a.protocolError(s.c, msg, kind, err.ResultCode, true, diam.ProtocolReplyNone, 0) - return - } - cea := new(smparser.CEA) - if err := cea.Parse(msg, smparser.ParseOptions{Role: smparser.Client, LocalApplications: s.localApps}); err != nil { - a.fail(fmt.Errorf("peer: invalid CEA: %w", err)) - if a.m.cfg.Settings.OnProtocolError != nil { - a.protocolError(s.c, msg, diam.ProtocolCEARejected, protocolResultCode(err), true, diam.ProtocolReplyNone, 0) - } - return - } - if compareIdentity(cea.OriginHost, a.cfg.Host) != 0 { - a.fail(errors.New("peer: CEA Origin-Host mismatch")) - a.protocolError(s.c, msg, diam.ProtocolCEARejected, 0, true, diam.ProtocolReplyNone, 0) - return - } - meta := smpeer.FromCEA(cea).Clone() - if err := a.admitDiscovery(s, meta); err != nil { - a.fail(err) - return - } - s.meta = meta - a.meta = s.meta.Clone() - s.releaseControl(s.cerHop) - if s.requests != nil { - s.requests.MatchAnswer(msg, true) - } - a.step(iCEA, s, msg) - return - } - if isI { - a.step(iNonCEA, s, msg) - result := uint32(0) - if e.messageErr != nil { - result = e.messageErr.ResultCode - } - a.protocolError(s.c, msg, diam.ProtocolHandshakeState, result, true, diam.ProtocolReplyNone, 0) - } else { - // RFC 6733 §5.6 has no R-Rcv-Message action in Wait-Returns. - // As in Wait-Conn-Ack/Elect, close all pre-CEA traffic on R. - reason := error(e.messageErr) - if e.messageErr == nil { - reason = errors.New("peer: message before answering connection handshake") - } - a.m.report(s, msg, reason) - s.close() - result := uint32(0) - if e.messageErr != nil { - result = e.messageErr.ResultCode - } - a.protocolError(s.c, msg, diam.ProtocolHandshakeState, result, true, diam.ProtocolReplyNone, 0) - } - return - } - if a.state != IOpen && a.state != ROpen && a.state != Closing { - a.m.report(s, msg, errors.New("peer: message before handshake")) - s.close() - result := uint32(0) - if e.messageErr != nil { - result = e.messageErr.ResultCode - } - a.protocolError(s.c, msg, diam.ProtocolHandshakeState, result, true, diam.ProtocolReplyNone, 0) - return - } - // RFC 6733 §5.6: Closing admits disconnect traffic only. The - // maintainer's drain policy also admits answers owned by requests already - // sent on this generation. Decide before validation, error answers and - // watchdog effects; ineligible traffic has no hooks or wire response. - if a.state == Closing { - if s != a.active { - return - } - disconnect := cmd == diam.DisconnectPeer && msg.Header.ApplicationID == 0 - if !disconnect && (req || !a.m.matchesPendingAnswer(s, msg)) { - return - } - } - if a.state != Closing && (e.messageErr == nil || !e.messageErr.Fatal) && cmd == diam.CapabilitiesExchange && req && msg.Header.ApplicationID == 0 { - a.watchdogReceiveAt(msg, e.receivedAt) - reason := errors.New("peer: ignoring CER on admitted connection; accepted CER renewal is disabled or ineligible") - a.m.reportFailure(s, msg, slog.LevelWarn, reason.Error(), reason) - a.protocolError(s.c, msg, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0) - return - } - - if headerErr := base.ValidateHeader(msg); headerErr != nil && !base.FatalHeaderError(e.messageErr) { - a.m.report(s, msg, headerErr) - if !req { - closing := e.messageErr != nil && e.messageErr.Fatal - a.protocolError(s.c, msg, diam.ProtocolInvalidHeader, headerErr.ResultCode, closing, diam.ProtocolReplyNone, 0) - return - } // RFC 6733 §§2.5 and 7: invalid DWA/DPA is discarded. - headerErr.Fatal = e.messageErr != nil && e.messageErr.Fatal - e.messageErr = headerErr - } - if e.messageErr != nil { - if a.state == Closing { - // RFC 6733 §5.6: the crossed/repeated DPR policy admits valid - // requests only. Preserve decoding diagnostics without an error - // answer that would introduce new Closing work. - a.protocolError(s.c, msg, diam.ProtocolMalformedMessage, e.messageErr.ResultCode, e.messageErr.Fatal, diam.ProtocolReplyNone, 0) - return - } - // The actor queued CEA before this error answer (RFC 6733 §§5.6, 7). - _ = a.m.answerMessageError(s, msg, e.messageErr, a.delivery) - return - } - - if s != a.active { - return - } - if a.state != Closing && !a.watchdogReceiveAt(msg, e.receivedAt) { - return - } - if err := base.ValidateMessage(msg); err != nil { - a.m.rejectBaseMessage(s, msg, err, false, a.state == Closing, a.delivery) - return - } - if rejection := base.ValidateRequest(msg, s.localApps, a.m.cfg.Settings.ValidateRequests, a.m.cfg.Settings.RejectUnknownMandatoryAVPs); rejection != nil { - a.m.rejectRequest(s, msg, rejection, false, a.state == Closing, a.delivery) - return - } - if a.state == Closing { - if cmd == diam.DisconnectPeer && !req && msg.Header.ApplicationID == 0 { - // RFC 6733 §§3, 5.4.2, 6.2.1: require a locally owned DPR - // in this generation, even when both identifiers are zero. - if a.pendingDPRGen != s.gen || a.pendingDPRCommand != cmd || - msg.Header.HopByHopID != a.pendingDPR || msg.Header.EndToEndID != a.pendingDPREnd { - a.m.report(s, msg, errors.New("peer: DPA identifiers mismatch")) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPA, 0, false, diam.ProtocolReplyNone, 0) - return - } - result, err := base.ValidateDPA(msg) - if err != nil { - a.m.report(s, msg, err) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPA, 0, false, diam.ProtocolReplyNone, 0) - return - } - // RFC 6733 §§5.4.2, 6.3: bind the structurally valid answer - // to the admitted identity before interpreting its Result-Code. - host, err := msg.FindAVP(avp.OriginHost, 0) - if err != nil || compareIdentity(host.Data.(datatype.DiameterIdentity), a.cfg.Host) != 0 { - a.m.report(s, msg, errors.New("peer: DPA Origin-Host mismatch")) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPA, 0, false, diam.ProtocolReplyNone, 0) - return - } - if result != diam.Success { - a.m.report(s, msg, fmt.Errorf("peer: DPA Result-Code %d", result)) - a.protocolError(s.c, msg, diam.ProtocolPeerRejected, result, true, diam.ProtocolReplyNone, 0) - } - // RFC 6733 §5.4: the DPA receiver initiates transport disconnect, - // including a valid unsuccessful answer; preserve its diagnostic. - a.pendingDPRGen, a.pendingDPRCommand = 0, 0 - if isI { - s.releaseControl(a.pendingDPR) - a.step(iDPA, s, msg) - } else { - s.releaseControl(a.pendingDPR) - a.step(rDPA, s, msg) - } - } else if cmd == diam.DisconnectPeer && req && a.validDPR(s, msg) { - // Crossed/repeated DPR interoperability policy: answer without - // rearming the first Closing deadline (RFC 6733 §5.6). - a.answerDPR(s, msg) - } else if !req { - a.m.receiveClosingAnswer(s, msg) - } - return - } - switch { - case cmd == diam.DeviceWatchdog && req && msg.Header.ApplicationID == 0: - var dwr base.DWR - if err := dwr.Parse(msg); err != nil { - a.m.report(s, msg, err) - a.protocolError(s.c, msg, diam.ProtocolInvalidDWR, 0, false, diam.ProtocolReplyNone, 0) - return - } - if compareIdentity(dwr.OriginHost, a.cfg.Host) != 0 { - a.m.report(s, msg, errors.New("peer: DWR Origin-Host mismatch")) - a.protocolError(s.c, msg, diam.ProtocolInvalidDWR, 0, false, diam.ProtocolReplyNone, 0) - return - } - if isI { - a.step(iDWR, s, msg) - } else { - a.step(rDWR, s, msg) - } - case cmd == diam.DeviceWatchdog && !req && msg.Header.ApplicationID == 0: - if isI { - a.step(iDWA, s, msg) - } else { - a.step(rDWA, s, msg) - } - case cmd == diam.DisconnectPeer && req && msg.Header.ApplicationID == 0: - if !a.validDPR(s, msg) { - return - } - if isI { - a.step(iDPR, s, msg) - } else { - a.step(rDPR, s, msg) - } - default: - if a.watchdog == WatchdogReopen { - return - } // RFC 3539 Appendix A: Throwaway(non-DWA). - if isI { - a.step(iMessage, s, msg) - } else { - a.step(rMessage, s, msg) - } - } -} -func (a *actor) validDPR(s *session, msg *diam.Message) bool { - if _, err := base.ValidateDPR(msg); err != nil { - a.m.report(s, msg, err) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPR, 0, false, diam.ProtocolReplyNone, 0) - return false - } - // RFC 6733 §§5.1, 5.4.1: accept a DPR only from this connection's peer. - host, err := msg.FindAVP(avp.OriginHost, 0) - if err != nil || compareIdentity(host.Data.(datatype.DiameterIdentity), a.cfg.Host) != 0 { - a.m.report(s, msg, errors.New("peer: DPR Origin-Host mismatch")) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPR, 0, false, diam.ProtocolReplyNone, 0) - return false - } - return true -} -func (a *actor) answerDPR(s *session, msg *diam.Message) { - if _, err := base.ValidateDPR(msg); err != nil { - a.m.report(s, msg, err) - a.protocolError(s.c, msg, diam.ProtocolInvalidDPR, 0, false, diam.ProtocolReplyNone, 0) - return - } - result := uint32(diam.Success) - if a.closingWatchdog || a.m.hasInFlightRequests(s) { - // RFC 6733 §5.4 recommends an error for likely in-flight messages. - // §7.1.5's DIAMETER_UNABLE_TO_COMPLY (5012) covers this unspecified - // rejection; no invalid AVP or unsupported command is implicated. - result = diam.UnableToComply - } - dpa, err := base.BuildDPAResult(msg, a.m.baseSettings(s.c), result) - if err == nil { - err = s.sendRequired(dpa, msg, false, result) - } - if err != nil { - a.m.reportLocal(s, msg, "peer: DPA answer failed; closing connection", err) - a.requiredReplyFailed(s, msg, result) } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: e.msg, Error: e.messageErr, ErrorInput: e.messageErr != nil, At: e.receivedAt, Sequence: e.receiveSequence}, a.delivery) } + func (a *actor) onGone(s *session) { if s == nil { return @@ -865,43 +614,17 @@ func (a *actor) step(ev psmEvent, s *session, msg *diam.Message) { } old := a.state a.state = row.next - if a.state == Closing && old != Closing && s != nil { - // Keep the in-flight decision after stopWatchdog invalidates its - // probe credit; a DWR sent before Closing still awaits an answer. - a.closingWatchdog = a.closingWatchdog || a.pendingWatchdog && !a.watchdogAnswered && a.watchdogGen == s.gen - // RFC 6733 §5.6: publish Closing admission before DPR/DPA effects. - // The pending lock fences Send reservations against this transition. - a.m.closeAdmission(s) - if s.requests != nil && a.closingWatchdog { - // Queuing DWR precedes native write-gate admission. RFC 6733 - // §5.4's in-flight decision uses that shared Closing boundary. - answer := &diam.Message{Header: &diam.Header{CommandCode: diam.DeviceWatchdog, - HopByHopID: a.watchdogHop, EndToEndID: a.watchdogEnd}} - a.closingWatchdog = s.requests.MatchAnswer(answer, false) - } - } + if old == Closing && a.state != Closing { a.pendingDPRGen, a.pendingDPRCommand = 0, 0 } switch ev { case iAck: a.stopTimer() - a.arm(a.m.cfg.Timers.CER) if a.i != nil { - local := a.m.baseSettings(a.i.c) - a.i.localApps = base.AdvertisedApplicationIDs(local) - m, err := base.BuildCER(a.m.dictionary(), local) - if err != nil { - a.failLocal("build CER", err) - return - } - if err := a.i.sendControl(m); err != nil { - a.failLocal("send CER", err) - return - } - a.i.cerHop = m.Header.HopByHopID - a.i.cerEnd = m.Header.EndToEndID + a.i.runProtocol(protocol.Input{Kind: protocol.Start}, a.i.takeStartup(), a.delivery) } + case iNack: if old == WaitConnAckElect { a.active = a.r @@ -957,7 +680,7 @@ func (a *actor) step(ev psmEvent, s *session, msg *diam.Message) { a.active = nil a.meta = nil case WaitReturns: - a.arm(a.m.cfg.Timers.CER) + // The surviving initiator retains its core CER deadline. } case iNonCEA: a.fail(errors.New("peer: non-CEA before handshake")) @@ -979,60 +702,30 @@ func (a *actor) step(ev psmEvent, s *session, msg *diam.Message) { a.protocolError(connection.c, nil, kind, 0, true, diam.ProtocolReplyNone, 0) } return - case iDWR, rDWR: - dwa, err := base.BuildDWA(msg, a.m.baseSettings(s.c)) - if err != nil { - a.failLocal("build DWA", err) - a.requiredReplyFailed(s, msg, diam.Success) - return - } - if err := s.sendRequired(dwa, msg, false, diam.Success); err != nil { - a.failLocal("send DWA", err) - a.requiredReplyFailed(s, msg, diam.Success) - return - } - case iDPR, rDPR: - a.stopTimer() - a.stopWatchdog() - cause, _ := base.ValidateDPR(msg) - // RFC 6733 §5.4.3: only REBOOTING permits periodic reconnection. - a.suppressReconnect = cause == uint32(sm.DisconnectBusy) || cause == uint32(sm.DisconnectDoNotWantToTalkToYou) - a.answerDPR(s, msg) - a.arm(a.m.cfg.Timers.Closing) case stop: a.stopTimer() - dpr, err := base.BuildDPR(a.m.dictionary(), a.m.baseSettings(s.c), uint32(a.closeCause)) - if err != nil { - a.failLocal("build DPR", err) - return + if s != nil { + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect, Cause: uint32(a.closeCause)}, a.delivery) } - if err := s.sendControl(dpr); err != nil { - a.failLocal("send DPR", err) + if a.state == Closed { return } - a.pendingDPR = dpr.Header.HopByHopID - a.pendingDPREnd = dpr.Header.EndToEndID - // RFC 6733 §§5.4.2, 6.2.1: Closing alone does not own a DPR. - a.pendingDPRGen, a.pendingDPRCommand = s.gen, dpr.Header.CommandCode - a.arm(a.m.cfg.Timers.Closing) case iDPA, rDPA: a.stopTimer() s.close() - a.active = nil - a.meta = nil - case iMessage, rMessage: - if msg.Header.CommandFlags&diam.RequestFlag == 0 { - a.m.receiveAnswer(s, msg, a.delivery) - } else { - a.m.unsupported(s, msg, a.delivery) - } + a.active, a.meta = nil, nil + } if a.active != nil && a.active.inbound && (a.state == ROpen) && !a.active.isRetired() { a.active.admitted.Store(true) } if old != IOpen && old != ROpen && (a.state == IOpen || a.state == ROpen) { a.closingWatchdog = false - a.onOpen() + a.stopReconnect() + if a.active != nil { + a.syncProtocol(a.active, protocol.Input{Kind: protocol.ReleaseAdmission}) + } + a.everOpen = true if a.state == Closed { return } @@ -1043,29 +736,31 @@ func (a *actor) sendCEA(s *session) bool { if s == nil { a.failLocal("send CEA", errors.New("missing responder")) return false - } // RFC 6733 §§5.3.2, 5.6. - msg := s.cerRequest - if msg == nil { + } + if s.candidate == 0 { a.failLocal("send CEA", errors.New("missing CER")) return false } - local := a.m.baseSettings(s.c) - s.localApps = base.AdvertisedApplicationIDs(local) - answer, err := base.BuildCEA(msg, local, diam.Success) - if err != nil { - a.failLocal("build CEA", err) - a.requiredReplyFailed(s, msg, diam.Success) - return false + s.applyProtocol(protocol.Input{Kind: protocol.Activate, Token: s.candidate, Message: s.cerRequest, Recovery: a.watchdog == WatchdogDown}, a.delivery) + return !s.c.Closed() && !s.isRetired() +} + +// Core admission selects this event; peer election decides which leg survives. +func (a *actor) openInitiator(s *session) { + row, ok := transition(a.state, iCEA) + if !ok { + return } - if err := s.sendRequired(answer, msg, false, diam.Success); err != nil { - if s.isRetired() { - return false - } - a.failLocal("send CEA", err) - a.requiredReplyFailed(s, msg, diam.Success) - return false + old := a.state + a.state = row.next + a.stopTimer() + a.stopReconnect() + if old == WaitReturns && a.r != nil { + a.r.close() + a.r = nil } - return true + a.active = s + a.meta = s.meta.Clone() } // dictionary preserves the configured parser identity; its nil fallback is diff --git a/diam/peer/answer_correlation_test.go b/diam/peer/answer_correlation_test.go index a359176..af77b92 100644 --- a/diam/peer/answer_correlation_test.go +++ b/diam/peer/answer_correlation_test.go @@ -119,7 +119,7 @@ func TestManagerCEACorrelationOnWire(t *testing.T) { } // Duplicate CEA cannot replace the identity learned on admission. write(t, c, correlationCEA(t, cer, "other.example.net")) - write(t, c, reviewDWR(t)) + write(t, c, watchdogRequest(t)) if answer := read(t, c); answer.Header.CommandCode != diam.DeviceWatchdog || code(t, answer) != diam.Success { t.Fatalf("duplicate CEA disturbed admission: %v", answer) } @@ -182,7 +182,7 @@ func correlationReceiver(t *testing.T) (*Manager, net.Conn, *fakeClock, <-chan d t.Fatal(err) } t.Cleanup(func() { _ = c.Close() }) - write(t, c, reviewCER(t)) + write(t, c, capabilitiesExchangeRequest(t)) if code(t, read(t, c)) != diam.Success { t.Fatal("CEA rejected") } @@ -268,40 +268,66 @@ func TestManagerDPAOwnership(t *testing.T) { t.Run(fault, func(t *testing.T) { a, s, _ := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s - a.stop(sm.DisconnectRebooting) - dpr := (<-s.writes).msg - gen, command := a.pendingDPRGen, a.pendingDPRCommand - switch fault { - case "no request": - a.pendingDPRGen = 0 - case "stale generation": - a.pendingDPRGen = s.gen + 1 - case "wrong command": - a.pendingDPRCommand = diam.DeviceWatchdog - case "zero identifiers": - dpr.Header.HopByHopID, dpr.Header.EndToEndID = 0, 0 - a.pendingDPR, a.pendingDPREnd = 0, 0 + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } + var request *diam.Message + var selected writeRequest + if fault == "no request" { + var err error + request, err = base.BuildDPR(a.m.dictionary(), testBase("known.example.net"), 0) + if err != nil { + t.Fatal(err) + } + a.onWire(event{s: s, msg: request}) + <-s.writes + } else { + if fault == "zero identifiers" { + a.m.nextHop = ^uint32(0) + a.m.endToEnd.next.Store(0) + } + a.stop(sm.DisconnectRebooting) + selected = <-s.writes + request = selected.msg } - answer, err := base.BuildDPA(dpr, testBase("known.example.net")) + answer, err := base.BuildDPA(request, testBase("known.example.net")) if err != nil { t.Fatal(err) } var events []diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events = append(events, e) } + switch fault { + case "stale generation": + stale := &session{m: a.m, c: newFakeConn(), gen: s.gen - 1} + a.onWire(event{s: stale, msg: answer}) + if a.state != Closing || s.c.Closed() || len(events) != 0 { + t.Fatal("stale connection completed disconnect") + } + case "wrong command": + wrong := *answer.Header + wrong.CommandCode = diam.DeviceWatchdog + a.onWire(event{s: s, msg: &diam.Message{Header: &wrong}}) + if a.state != Closing || s.c.Closed() || len(events) != 0 { + t.Fatal("different command completed disconnect") + } + } a.onWire(event{s: s, msg: answer}) - if fault != "zero identifiers" { + if fault == "no request" { if a.state != Closing || s.c.Closed() || len(events) != 1 || events[0].Err.Kind() != diam.ProtocolInvalidDPA { - t.Fatalf("unowned DPA completed disconnect: state=%s closed=%t events=%v", a.state, s.c.Closed(), events) + t.Fatalf("unsolicited DPA: state=%s events=%v", a.state, events) } - // A rejected answer must leave the actual owned request consumable. - a.pendingDPRGen, a.pendingDPRCommand = gen, command - a.onWire(event{s: s, msg: answer}) - } else if len(events) != 0 { - t.Fatalf("owned zero identifiers rejected: %v", events) + return + } + if fault == "zero identifiers" && (request.Header.HopByHopID != 0 || request.Header.EndToEndID != 0) { + t.Fatal("fixture did not allocate zero identifiers") } if a.state != Closed || !s.c.Closed() || a.pendingDPRGen != 0 || a.pendingDPRCommand != 0 { t.Fatalf("owned DPA did not release disconnect: state=%s gen=%d command=%d", a.state, a.pendingDPRGen, a.pendingDPRCommand) } + fixtureCompleteWrite(s, selected) + if len(s.protocolSnapshot().Controls()) != 0 { + t.Fatal("answered DPR retained its completed writer") + } }) } } diff --git a/diam/peer/base_validation_round2_test.go b/diam/peer/base_message_validation_test.go similarity index 96% rename from diam/peer/base_validation_round2_test.go rename to diam/peer/base_message_validation_test.go index 4cf1b36..db67913 100644 --- a/diam/peer/base_validation_round2_test.go +++ b/diam/peer/base_message_validation_test.go @@ -60,7 +60,7 @@ func TestBaseValidationPermanentErrorContract(t *testing.T) { } } -type round2Fault struct { +type baseValueFault struct { name string field uint32 result uint32 @@ -68,8 +68,8 @@ type round2Fault struct { add bool } -func round2Faults() []round2Fault { - return []round2Fault{ +func baseValueFaults() []baseValueFault { + return []baseValueFault{ {"destination host", avp.DestinationHost, diam.AVPNotAllowed, datatype.DiameterIdentity("destination.example.net"), true}, {"destination realm", avp.DestinationRealm, diam.AVPNotAllowed, datatype.DiameterIdentity("example.net"), true}, {"host high octet", avp.OriginHost, diam.InvalidAVPValue, datatype.DiameterIdentity("peer\xff.example"), false}, @@ -82,7 +82,7 @@ func round2Faults() []round2Fault { } } -func (f round2Fault) apply(t *testing.T, m *diam.Message) { +func (f baseValueFault) apply(t *testing.T, m *diam.Message) { t.Helper() if f.add { m.AddAVP(diam.NewAVP(f.field, avp.Mbit, 0, f.value)) @@ -94,7 +94,7 @@ func (f round2Fault) apply(t *testing.T, m *diam.Message) { func TestBaseValidationValuesRequestContract(t *testing.T) { for _, frontend := range []string{"sm", "manager"} { for _, command := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { - for _, fault := range round2Faults()[2:] { + for _, fault := range baseValueFaults()[2:] { if fault.field == avp.SupportedVendorID && command != diam.CapabilitiesExchange { continue } @@ -113,7 +113,7 @@ func TestBaseValidationValuesAnswerContract(t *testing.T) { for _, frontend := range []string{"sm", "manager"} { for _, command := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { for _, flags := range []uint8{0, diam.ErrorFlag} { - for _, fault := range round2Faults() { + for _, fault := range baseValueFaults() { if fault.field == avp.SupportedVendorID && command != diam.CapabilitiesExchange { continue } diff --git a/diam/peer/capability_order_test.go b/diam/peer/capability_order_test.go index 8a2fbb3..d0b5702 100644 --- a/diam/peer/capability_order_test.go +++ b/diam/peer/capability_order_test.go @@ -16,6 +16,7 @@ import ( "github.com/gomaja/go-diameter/diam/avp" "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" ) type capabilityWireConn struct { @@ -42,6 +43,10 @@ func TestQueuedCERPrecedesMalformedRequest(t *testing.T) { c := &capabilityWireConn{fakeConn: newFakeConn(testDict), wire: make(chan []byte, 8)} // Deliberately hold the dispatcher until both reader callbacks have run. s := &session{m: m, c: c, inbound: true, writes: make(chan writeRequest, 8), ingress: make(chan incoming, 8), closed: make(chan struct{})} + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) m.sessions[c] = s defer func() { s.close(); m.unregister(s); closeManager(t, m, nil) }() cer, err := base.BuildCER(testDict, testBase("known.example.net")) diff --git a/diam/peer/cea_election_failure_test.go b/diam/peer/cea_election_failure_test.go new file mode 100644 index 0000000..0d69586 --- /dev/null +++ b/diam/peer/cea_election_failure_test.go @@ -0,0 +1,53 @@ +package peer + +import ( + "errors" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +func TestManagerInvalidCEAHeaderClosesElectionLegs(t *testing.T) { + a, s, _ := failureReportingActor(t) + a.m.cfg.Settings.OriginHost = "aaa.example.net" + cer := reportingInitiator(t, a, s, true) + r := &session{m: a.m, c: newFakeConn(), actor: a, inbound: true, gen: 2, writes: make(chan writeRequest, 8), closed: make(chan struct{})} + t.Cleanup(r.close) + if err := r.initProtocol(); err != nil { + t.Fatal(err) + } + r.runProtocol(protocol.Input{Kind: protocol.Start}, r.takeStartup(), nil) + // Hold responder admission while an initiator CER is outstanding. + a.state = WaitICEA + request, err := base.BuildCER(a.m.dictionary(), testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + r.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: request}, nil) + if a.state != WaitReturns || a.r != r || r.candidate == 0 { + t.Fatalf("candidate state=%s r=%v candidate=%d", a.state, a.r == r, r.candidate) + } + cea, err := base.BuildCEA(cer, testBase("known.example.net"), diam.Success) + if err != nil { + t.Fatal(err) + } + cea.Header.CommandFlags |= diam.ProxiableFlag + a.onWire(event{s: s, msg: cea}) + var retained bool + for len(a.m.callbackQ) != 0 { + e := <-a.m.callbackQ + var cause *diam.MessageError + if errors.As(e.Reason, &cause) && cause.ResultCode == diam.InvalidHDRBits { + retained = true + } + } + if !retained { + t.Error("CEA failure notification lost its typed header error") + } + a.handle(event{kind: connGone, s: s}) + if a.state != Closed || !r.c.Closed() { + t.Fatalf("invalid correlated CEA left state=%s responderClosed=%v responderWrites=%d", a.state, r.c.Closed(), len(r.writes)) + } +} diff --git a/diam/peer/closing_wire_test.go b/diam/peer/closing_wire_test.go index 5b35ef7..fda70fa 100644 --- a/diam/peer/closing_wire_test.go +++ b/diam/peer/closing_wire_test.go @@ -106,9 +106,9 @@ func TestManagerClosingAdmissionOnWire(t *testing.T) { msg := diam.NewRequest(command.code, command.app, m.dictionary()) switch command.code { case diam.CapabilitiesExchange: - msg = reviewCER(t) + msg = capabilitiesExchangeRequest(t) case diam.DeviceWatchdog: - msg = reviewDWR(t) + msg = watchdogRequest(t) } if !request { msg = msg.Answer(diam.Success) diff --git a/diam/peer/protocol_error_round2_test.go b/diam/peer/control_answer_diagnostics_test.go similarity index 91% rename from diam/peer/protocol_error_round2_test.go rename to diam/peer/control_answer_diagnostics_test.go index d582be0..45f55ea 100644 --- a/diam/peer/protocol_error_round2_test.go +++ b/diam/peer/control_answer_diagnostics_test.go @@ -74,7 +74,7 @@ func TestProtocolErrorManagerDWAResultCodeOnWire(t *testing.T) { t.Fatalf("handshake failed: %v", answer) } awaitState(t, m, ROpen) - originalConn := round2SessionConn(t, m, remote) + originalConn := protocolDiagnosticSessionConn(t, m, remote) clock.fire() request := read(t, remote) if request.Header.CommandCode != diam.DeviceWatchdog || request.Header.CommandFlags&diam.RequestFlag == 0 { @@ -92,7 +92,7 @@ func TestProtocolErrorManagerDWAResultCodeOnWire(t *testing.T) { if tc.duplicate { answer.AVP = append([]*diam.AVP{diam.NewAVP(avp.ResultCode, avp.Mbit, 0, datatype.Unsigned32(2001))}, answer.AVP...) } else { - round2PrependNestedResult(answer, 2001) + prependNestedResultCode(answer, 2001) } answer.Header.MessageLength = uint32(answer.Len()) write(t, remote, answer) @@ -102,7 +102,7 @@ func TestProtocolErrorManagerDWAResultCodeOnWire(t *testing.T) { kind, result = diam.ProtocolInvalidDWA, diam.AVPOccursTooManyTimes } protocolErrorCheck(t, e, originalConn, *answer.Header, kind, result, false, diam.ProtocolReplyNone, 0, 0) - logged := round2CheckOnlyProtocolLog(t, records) + logged := onlyProtocolDiagnosticLogError(t, records) if tc.duplicate { var messageErr *diam.MessageError if !errors.As(logged, &messageErr) || messageErr.ResultCode != diam.AVPOccursTooManyTimes { @@ -112,13 +112,13 @@ func TestProtocolErrorManagerDWAResultCodeOnWire(t *testing.T) { t.Fatalf("DWA diagnostic changed: %v", logged) } // A rejected DWA neither answers an answer nor closes the connection. - next := reviewDWR(t) + next := watchdogRequest(t) write(t, remote, next) response := read(t, remote) if code(t, response) != 2001 || response.Header.HopByHopID != next.Header.HopByHopID || response.Header.EndToEndID != next.Header.EndToEndID { t.Fatalf("unexpected answer: %+v", response) } - round2NoExtraProtocolEvent(t, events) + requireNoAdditionalProtocolEvent(t, events) }) } } @@ -132,8 +132,8 @@ func TestProtocolErrorManagerCEAParserCodeOnWire(t *testing.T) { result uint32 mutate func(*diam.Message) }{ - {"strict nested success", true, 5012, func(m *diam.Message) { round2PrependNestedResult(m, 2001) }}, - {"non-strict nested success", false, 5012, func(m *diam.Message) { round2PrependNestedResult(m, 2001) }}, + {"strict nested success", true, 5012, func(m *diam.Message) { prependNestedResultCode(m, 2001) }}, + {"non-strict nested success", false, 5012, func(m *diam.Message) { prependNestedResultCode(m, 2001) }}, {"non-strict malformed Address", false, 5004, func(m *diam.Message) { for _, a := range m.AVP { if a.Code == avp.ResultCode { @@ -181,7 +181,7 @@ func TestProtocolErrorManagerCEAParserCodeOnWire(t *testing.T) { if request.Header.CommandCode != diam.CapabilitiesExchange || request.Header.CommandFlags&diam.RequestFlag == 0 { t.Fatalf("not CER: %+v", request.Header) } - originalConn := round2SessionConn(t, m, remote) + originalConn := protocolDiagnosticSessionConn(t, m, remote) answer, err := base.BuildCEA(request, testBase("known.example.net"), 5012) if err != nil { t.Fatal(err) @@ -191,7 +191,7 @@ func TestProtocolErrorManagerCEAParserCodeOnWire(t *testing.T) { write(t, remote, answer) e := protocolErrorReceive(t, events) protocolErrorCheck(t, e, originalConn, *answer.Header, diam.ProtocolCEARejected, tc.result, true, diam.ProtocolReplyNone, 0, 0) - logged := round2CheckOnlyProtocolLog(t, records) + logged := onlyProtocolDiagnosticLogError(t, records) if tc.result == 5004 { var parseErr *diam.MessageError if !errors.As(logged, &parseErr) || parseErr.ResultCode != 5004 || parseErr.FailedAVP == nil || parseErr.FailedAVP.Code != avp.HostIPAddress { @@ -211,17 +211,17 @@ func TestProtocolErrorManagerCEAParserCodeOnWire(t *testing.T) { t.Fatalf("want EOF without an answer, got bytes=%d err=%v", n, err) } awaitState(t, m, Closed) - round2NoExtraProtocolEvent(t, events) + requireNoAdditionalProtocolEvent(t, events) }) } } -func round2PrependNestedResult(m *diam.Message, result uint32) { +func prependNestedResultCode(m *diam.Message, result uint32) { failed := diam.NewAVP(avp.FailedAVP, avp.Mbit, 0, &diam.GroupedAVP{AVP: []*diam.AVP{diam.NewAVP(avp.ResultCode, avp.Mbit, 0, datatype.Unsigned32(result))}}) m.AVP = append([]*diam.AVP{failed}, m.AVP...) } -func round2SessionConn(t *testing.T, m *Manager, remote net.Conn) diam.Conn { +func protocolDiagnosticSessionConn(t *testing.T, m *Manager, remote net.Conn) diam.Conn { t.Helper() m.mu.RLock() defer m.mu.RUnlock() @@ -234,7 +234,7 @@ func round2SessionConn(t *testing.T, m *Manager, remote net.Conn) diam.Conn { return nil } -func round2CheckOnlyProtocolLog(t *testing.T, records *logtest.Recorder) error { +func onlyProtocolDiagnosticLogError(t *testing.T, records *logtest.Recorder) error { t.Helper() got := records.Records() if len(got) != 1 || got[0].Level != slog.LevelWarn || got[0].Message != "peer: protocol error" { @@ -247,7 +247,7 @@ func round2CheckOnlyProtocolLog(t *testing.T, records *logtest.Recorder) error { return err } -func round2NoExtraProtocolEvent(t *testing.T, events <-chan diam.ProtocolErrorEvent) { +func requireNoAdditionalProtocolEvent(t *testing.T, events <-chan diam.ProtocolErrorEvent) { t.Helper() select { case extra := <-events: diff --git a/diam/peer/core_activity_wire_test.go b/diam/peer/core_activity_wire_test.go new file mode 100644 index 0000000..eb03660 --- /dev/null +++ b/diam/peer/core_activity_wire_test.go @@ -0,0 +1,379 @@ +package peer + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" +) + +type coreActivityWire struct { + m *Manager + c net.Conn + clock *fakeClock + events chan diam.ProtocolErrorEvent + a *actor + s *session + listener net.Listener + dialed bool +} + +// The fixture enters through Manager's TCP accept/dial paths and exchanges +// actual CER/CEA frames. Only timer delivery and the actor completion fence are +// controlled by the test; protocol inputs always arrive through the socket. +func newCoreActivityWire(t *testing.T, dialed bool) *coreActivityWire { + t.Helper() + p := &coreActivityWire{clock: &fakeClock{}, events: make(chan diam.ProtocolErrorEvent, 32), dialed: dialed} + cfg := testSettings("local.example.net") + cfg.ValidateRequests = true + cfg.OnProtocolError = func(e diam.ProtocolErrorEvent) { p.events <- e } + var err error + p.m, err = New(Config{Settings: cfg, Clock: p.clock, Timers: Timers{TwInit: time.Hour}, watchdogTiming: &watchdogTiming{floor: time.Millisecond}}) + if err != nil { + t.Fatal(err) + } + peer := PeerConfig{Host: "known.example.net", NoAutoReconnect: true} + var server *diam.Server + if dialed { + p.listener, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = p.listener.Close() }) + peer.Endpoints = []Endpoint{{Network: "tcp", Address: p.listener.Addr().String()}} + } + if err := p.m.AddPeer(peer); err != nil { + t.Fatal(err) + } + if dialed { + if err := p.m.Start(context.Background()); err != nil { + t.Fatal(err) + } + } else { + p.listener, server = startReceiver(t, p.m) + } + t.Cleanup(func() { closeManager(t, p.m, server) }) + t.Cleanup(func() { + if p.c != nil { + _ = p.c.Close() + } + }) + p.connect(t) + return p +} + +func (p *coreActivityWire) connect(t *testing.T) { + t.Helper() + var err error + if p.dialed { + if err := p.listener.(*net.TCPListener).SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + p.c, err = p.listener.Accept() + if err != nil { + t.Fatal(err) + } + cer := read(t, p.c) + if cer.Header.CommandCode != diam.CapabilitiesExchange || cer.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("want CER, got %+v", cer.Header) + } + write(t, p.c, correlationCEA(t, cer, "known.example.net")) + awaitState(t, p.m, IOpen) + } else { + p.c, err = net.Dial("tcp", p.listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + write(t, p.c, capabilitiesExchangeRequest(t)) + if answer := read(t, p.c); answer.Header.CommandCode != diam.CapabilitiesExchange || code(t, answer) != diam.Success { + t.Fatalf("handshake answer: %+v", answer) + } + awaitState(t, p.m, ROpen) + } + p.m.mu.RLock() + p.a = p.m.peers["known.example.net"] + p.s = p.a.activeSession.Load() + p.m.mu.RUnlock() + p.fence(t) + if p.s == nil { + t.Fatal("admitted connection has no session") + } +} + +// commit waits for the actor to finish the preceding decision, including all +// deferred watchdog work. No message or timer is injected by this fence. +func (p *coreActivityWire) fence(t *testing.T) { + t.Helper() + done := make(chan struct{}) + go func() { p.a.commit(event{kind: "test-activity-fence"}, newProtocolDelivery()); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("actor did not finish its production receive decision") + } +} + +type coreActivityState struct { + health WatchdogState + pending bool + num int + timer Timer + token uint64 +} + +func (p *coreActivityWire) state(t *testing.T) coreActivityState { + t.Helper() + var state coreActivityState + d := newProtocolDelivery() + p.a.commit(event{testHook: func() { + state = coreActivityState{p.a.watchdog, p.a.pendingWatchdog, p.a.numDWA, p.a.wdTimer, p.a.wdToken} + }}, d) + return state +} + +func (p *coreActivityWire) expire(t *testing.T) { + t.Helper() + state := p.state(t) + timer, ok := state.timer.(*fakeTimer) + if !ok { + t.Fatalf("production watchdog timer = %T", state.timer) + } + timer.mu.Lock() + active := timer.active + timer.active = false + timer.mu.Unlock() + if !active { + t.Fatal("production watchdog timer was already stopped") + } + timer.f() + p.fence(t) +} + +func (p *coreActivityWire) suspect(t *testing.T) *diam.Message { + t.Helper() + if got := p.state(t); got.health != WatchdogOkay || got.pending { + t.Fatalf("initial watchdog = %+v", got) + } + p.expire(t) + probe := read(t, p.c) + if probe.Header.CommandCode != diam.DeviceWatchdog || probe.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("want owned DWR, got %+v", probe.Header) + } + p.expire(t) + if got := p.state(t); got.health != WatchdogSuspect || !got.pending || p.m.Peers()[0].Eligible { + t.Fatalf("unanswered probe did not reach SUSPECT: %+v", got) + } + return probe +} + +func (p *coreActivityWire) receiveError(t *testing.T, msg *diam.Message, reply bool) { + t.Helper() + write(t, p.c, msg) + if reply { + answer := read(t, p.c) + if answer.Header.CommandCode != msg.Header.CommandCode || answer.Header.HopByHopID != msg.Header.HopByHopID || answer.Header.EndToEndID != msg.Header.EndToEndID || answer.Header.CommandFlags&diam.RequestFlag != 0 || code(t, answer) == diam.Success { + t.Fatalf("invalid request response = %+v", answer) + } + } + e := protocolErrorReceive(t, p.events) + if e.Conn != p.s.c || !e.Message.HasHeader || e.Message.Header.CommandCode != msg.Header.CommandCode || e.Message.Header.HopByHopID != msg.Header.HopByHopID || e.Message.Header.EndToEndID != msg.Header.EndToEndID || e.Closing { + t.Fatalf("invalid input notification = %+v", e) + } + wantReply := diam.ProtocolReplyNone + if reply { + wantReply = diam.ProtocolReplyQueued + } + if e.Reply != wantReply { + t.Fatalf("invalid input reply disposition = %v, want %v", e.Reply, wantReply) + } + p.fence(t) +} + +func coreActivityFault(t *testing.T, p *coreActivityWire, fault string) (*diam.Message, bool) { + t.Helper() + m := watchdogRequest(t) + reply := true + switch fault { + case "repeated-cer", "malformed-repeated-cer": + m = capabilitiesExchangeRequest(t) + m.AVP = nil + reply = false + if fault == "malformed-repeated-cer" { + m.AddAVP(diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2})) + } + case "decode": + m.AddAVP(diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2})) + case "header": + m.Header.ApplicationID = 9 + case "grammar": + m.AVP = nil + case "request-validation": + m = outboundRequest("local.example.net", "example.net") + m.AVP = nil + case "application": + m = diam.NewMessage(0xfedc, diam.RequestFlag, 0xfefe, 123, 124, p.m.dictionary()) + case "bound-host", "bound-host-dpr": + if fault == "bound-host-dpr" { + m = closingDPR(t, p.m) + } + host, err := m.FindAVP(avp.OriginHost, 0) + if err != nil { + t.Fatal(err) + } + host.Data = datatype.DiameterIdentity("other.example.net") + reply = false + case "unsolicited-cea": + m = correlationCEA(t, capabilitiesExchangeRequest(t), "known.example.net") + reply = false + case "unsolicited-dpa": + var err error + m, err = base.BuildDPA(closingDPR(t, p.m), testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + reply = false + default: + t.Fatalf("unknown activity fault %q", fault) + } + return m, reply +} + +// RFC 3539 §3.4.1 [2], Appendix A, and the adopted #108 receive policy: +// decoded invalid traffic refreshes Tw independently of watchdog health and +// Pending. RFC 6733 §§3, 5.3–5.5, 6.2.1 govern validation and answer ownership. +func TestManagerCoreInvalidActivityTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, fault := range []string{"malformed-repeated-cer", "repeated-cer", "decode", "header", "grammar", "request-validation", "application", "bound-host", "bound-host-dpr", "unsolicited-cea", "unsolicited-dpa"} { + t.Run(fmt.Sprintf("dialed=%t/%s", dialed, fault), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + p.suspect(t) + before := p.state(t) + msg, reply := coreActivityFault(t, p, fault) + p.receiveError(t, msg, reply) + after := p.state(t) + if after.timer == nil || after.timer == before.timer || after.token == before.token { + t.Errorf("decoded invalid receipt did not reset Tw: before=%+v after=%+v", before, after) + } + if after.health != WatchdogSuspect || after.pending != before.pending || after.num != before.num || p.m.Peers()[0].Eligible { + t.Errorf("decoded invalid receipt earned health or Pending credit: before=%+v after=%+v", before, after) + } + p.m.pendingMu.Lock() + controls := len(p.m.controls[p.s]) + p.m.pendingMu.Unlock() + if controls != 1 || !p.s.requests.HasPending() { + t.Errorf("invalid receipt changed outstanding DWR ownership: controls=%d nativePending=%t", controls, p.s.requests.HasPending()) + } + // A callback selected by the old timer must be harmless after reset. + before.timer.(*fakeTimer).f() + stale := p.state(t) + if stale != after { + t.Errorf("pre-receipt timer changed refreshed watchdog: before=%+v after=%+v", after, stale) + } + }) + } + } +} + +// Valid non-DWA traffic restores SUSPECT but cannot complete the owned DWR. +// Only its successful correlated DWA releases Pending (RFC 3539 Appendix A). +func TestManagerCoreValidActivityAndOwnedDWATCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + t.Run(fmt.Sprintf("dialed=%t", dialed), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + probe := p.suspect(t) + request := watchdogRequest(t) + write(t, p.c, request) + answer := read(t, p.c) + if answer.Header.CommandCode != diam.DeviceWatchdog || answer.Header.HopByHopID != request.Header.HopByHopID || answer.Header.EndToEndID != request.Header.EndToEndID || code(t, answer) != diam.Success { + t.Fatalf("valid DWR response = %+v", answer) + } + if got := p.state(t); got.health != WatchdogOkay || !got.pending || !p.m.Peers()[0].Eligible { + t.Fatalf("valid DWR changed probe ownership or failed recovery: %+v", got) + } + dwa, err := base.BuildDWA(probe, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + write(t, p.c, dwa) + // A following request's response fences the preceding wire receive; the + // Manager's ingress queue preserves connection order through dispatch. + write(t, p.c, watchdogRequest(t)) + _ = read(t, p.c) + if got := p.state(t); got.health != WatchdogOkay || got.pending || p.s.requests.HasPending() { + t.Fatalf("owned successful DWA retained watchdog/native ownership: %+v", got) + } + p.m.pendingMu.Lock() + controls := len(p.m.controls[p.s]) + p.m.pendingMu.Unlock() + if controls != 0 { + t.Fatalf("owned DWA retained %d control identifiers", controls) + } + }) + } +} + +// Reconnect enters REOPEN through the real transport lifecycle. The adopted +// #108 policy refreshes Tw on decoded traffic while preserving Appendix A's +// three successful DWA requirement and application admission gate. +func TestManagerCoreReopenActivityTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, fault := range []string{"valid-dwr", "decode", "repeated-cer"} { + t.Run(fmt.Sprintf("dialed=%t/%s", dialed, fault), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + _ = p.c.Close() + awaitState(t, p.m, Closed) + p.fence(t) + if p.a.watchdog != WatchdogDown { + t.Fatalf("transport loss watchdog = %s", p.a.watchdog) + } + if dialed { + p.a.post(event{kind: start}) + } + p.connect(t) + probe := read(t, p.c) + if probe.Header.CommandCode != diam.DeviceWatchdog || probe.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("REOPEN probe = %+v", probe.Header) + } + before := p.state(t) + if before.health != WatchdogReopen || !before.pending || before.num != 0 || p.m.Peers()[0].Eligible { + t.Fatalf("reconnect bypassed REOPEN: %+v", before) + } + if fault == "valid-dwr" { + write(t, p.c, watchdogRequest(t)) + _ = read(t, p.c) + p.fence(t) + } else { + msg, reply := coreActivityFault(t, p, fault) + p.receiveError(t, msg, reply) + } + after := p.state(t) + if after.timer == nil || after.timer == before.timer || after.token == before.token || after.health != WatchdogReopen || !after.pending || after.num != 0 || p.m.Peers()[0].Eligible { + t.Errorf("REOPEN activity failed reset-only contract: before=%+v after=%+v", before, after) + } + for want := 1; want <= 3; want++ { + dwa, err := base.BuildDWA(probe, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + write(t, p.c, dwa) + write(t, p.c, watchdogRequest(t)) + _ = read(t, p.c) + got := p.state(t) + if got.pending || got.num != want || (got.health == WatchdogOkay) != (want == 3) || p.m.Peers()[0].Eligible != (want == 3) { + t.Fatalf("recovery DWA %d: %+v", want, got) + } + if want != 3 { + p.expire(t) + probe = read(t, p.c) + } + } + }) + } + } +} diff --git a/diam/peer/core_disconnect_wire_test.go b/diam/peer/core_disconnect_wire_test.go new file mode 100644 index 0000000..5a74000 --- /dev/null +++ b/diam/peer/core_disconnect_wire_test.go @@ -0,0 +1,48 @@ +package peer + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/base" +) + +// RFC 6733 §5.4.3: the first valid peer cause governs reconnect suppression, +// including a DPR received while our own DPR is awaiting its answer. +func TestManagerCrossedDPRKeepsFirstPeerCauseTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, cause := range []uint32{0, 1, 2} { + t.Run(fmt.Sprintf("dialed=%t/cause=%d", dialed, cause), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + p.a.post(event{kind: stop}) + local := read(t, p.c) + if local.Header.CommandCode != diam.DisconnectPeer || local.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("local shutdown sent %+v", local.Header) + } + for _, receivedCause := range []uint32{cause, (cause + 1) % 3} { + request, err := base.BuildDPR(p.m.dictionary(), testBase("known.example.net"), receivedCause) + if err != nil { + t.Fatal(err) + } + write(t, p.c, request) + answer := read(t, p.c) + if answer.Header.CommandCode != diam.DisconnectPeer || answer.Header.HopByHopID != request.Header.HopByHopID || answer.Header.EndToEndID != request.Header.EndToEndID || code(t, answer) != diam.Success { + t.Fatalf("crossed DPR answer = %+v", answer) + } + var suppressed bool + p.a.commit(event{testHook: func() { suppressed = p.a.suppressReconnect }}, newProtocolDelivery()) + if suppressed != (cause != 0) { + t.Fatalf("first peer cause %d, later %d: suppression=%t", cause, receivedCause, suppressed) + } + } + answer, err := base.BuildDPA(local, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + write(t, p.c, answer) + awaitState(t, p.m, Closed) + }) + } + } +} diff --git a/diam/peer/core_fixture_test.go b/diam/peer/core_fixture_test.go new file mode 100644 index 0000000..d0a56b8 --- /dev/null +++ b/diam/peer/core_fixture_test.go @@ -0,0 +1,196 @@ +package peer + +import ( + "errors" + "fmt" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +// bootstrapSessionProtocol admits a fixture through the production responder +// handshake. The session keeps the original Manager's allocator and executor; +// setup-only actor notifications go to a private queue. Call before starting +// any session worker or actor loop. +func bootstrapSessionProtocol(s *session) error { + if s == nil || s.actor == nil { + return errors.New("fixture requires an actor-bound session") + } + if s.core != nil { + return nil + } + a, manager := s.actor, s.actor.m + health, pending, num, peerState, failures := a.watchdog, a.pendingWatchdog, a.numDWA, a.state, a.failures + if a.events == nil { + a.events = make(chan event, 64) + } + if a.done == nil { + a.done = make(chan struct{}) + } + if s.writes == nil { + s.writes = make(chan writeRequest, 64) + } + if s.closed == nil { + s.closed = make(chan struct{}) + } + if manager.controls == nil { + manager.controls = make(map[*session]map[uint32]struct{}) + } + // Only actor publication is muted. s.m remains manager throughout startup. + a.m = &Manager{cfg: manager.cfg, callbackQ: make(chan PeerEvent, 128)} + defer func() { a.m = manager; a.failures = failures }() + a.state = Closed + a.active, a.r, a.i = nil, nil, nil + a.nextGen = s.gen + if a.nextGen > 0 { + a.nextGen-- + } + if health == WatchdogDown || health == WatchdogReopen { + a.watchdog = WatchdogDown + } else { + a.watchdog = WatchdogInitial + } + a.publish(nil) + s.inbound = true + if err := s.initProtocol(); err != nil { + return err + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) + remote := testBase(string(a.cfg.Host)) + cer, err := base.BuildCER(s.m.dictionary(), remote) + if err != nil { + return err + } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: cer}, nil) + if s.core.Snapshot().Phase() != protocol.Open { + return fmt.Errorf("fixture handshake phase=%v", s.core.Snapshot().Phase()) + } + select { + case w := <-s.writes: + if w.msg == nil || w.msg.Header.CommandCode != diam.CapabilitiesExchange || w.msg.Header.CommandFlags&diam.RequestFlag != 0 { + return errors.New("fixture handshake selected no CEA") + } + fixtureCompleteWrite(s, w) + default: + return errors.New("fixture handshake did not queue CEA") + } + if peerState == IOpen { + a.state = IOpen + a.i = s + a.r = nil + } else { + a.state = ROpen + a.r = s + a.i = nil + } + a.active = s + if health == WatchdogSuspect { + fixtureExpireWatchdog(a) + fixtureConsumeProbe(s) + fixtureExpireWatchdog(a) + } else if health == WatchdogReopen || health == WatchdogDown { + // REOPEN starts with a real queued recovery probe. Credit earlier rounds + // using the same peer identity and allocator reservation as normal traffic. + for i := 0; i < num; i++ { + request := fixtureConsumeProbe(s) + answer, err := base.BuildDWA(request, testBase(string(a.cfg.Host))) + if err != nil { + return err + } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: answer}, nil) + if i+1 < num || pending { + fixtureExpireWatchdog(a) + } + } + if num == 0 && !pending && health == WatchdogReopen { + fixtureConsumeProbe(s) + } + } else if pending { + fixtureExpireWatchdog(a) + fixtureConsumeProbe(s) + } + a.publish(nil) + return nil +} + +func ensureSessionProtocol(s *session) { + if err := bootstrapSessionProtocol(s); err != nil { + panic(err) + } +} + +func fixtureCompleteWrite(s *session, w writeRequest) { + s.writeProtocol(w) + for len(s.actor.events) > 0 { + s.actor.handle(<-s.actor.events) + } +} + +func fixtureConsumeProbe(s *session) *diam.Message { + select { + case w := <-s.writes: + if w.msg == nil || w.msg.Header.CommandCode != diam.DeviceWatchdog || w.msg.Header.CommandFlags&diam.RequestFlag == 0 { + panic("fixture expected DWR") + } + fixtureCompleteWrite(s, w) + return w.msg + default: + panic("fixture has no selected DWR") + } +} + +func fixtureExpireWatchdog(a *actor) { + s := a.active + if s == nil { + return + } + ensureSessionProtocol(s) + timer := s.protocolSnapshot().Timer(protocol.WatchdogTimer) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.WatchdogTimer, Token: timer.Token, At: timer.Deadline}, nil) +} + +// Compatibility names are test-only executors, never alternate protocol logic. +func (a *actor) watchdogTick() { + fixtureExpireWatchdog(a) +} + +func (a *actor) sendWatchdog() { + fixtureExpireWatchdog(a) +} + +func (a *actor) watchdogReceive(msg *diam.Message) bool { + return a.watchdogReceiveAt(msg, a.m.now()) +} + +func (a *actor) watchdogReceiveAt(msg *diam.Message, at time.Time) bool { + if a.active == nil { + return false + } + ensureSessionProtocol(a.active) + s := a.active + before := s.protocolSnapshot().Watchdog() + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: msg, At: at}, nil) + after := s.protocolSnapshot().Watchdog() + if msg.Header.CommandCode == diam.DeviceWatchdog && msg.Header.CommandFlags&diam.RequestFlag == 0 { + return before.Pending && !after.Pending + } + return true +} + +func (a *actor) onOpen() { + s := a.active + if s == nil { + s = a.r + } + if s == nil { + s = a.i + } + ensureSessionProtocol(s) +} + +func (a *actor) answerDPR(s *session, msg *diam.Message) { + ensureSessionProtocol(s) + a.onWire(event{s: s, msg: msg}) +} diff --git a/diam/peer/core_receive_cost_test.go b/diam/peer/core_receive_cost_test.go new file mode 100644 index 0000000..c428867 --- /dev/null +++ b/diam/peer/core_receive_cost_test.go @@ -0,0 +1,20 @@ +package peer + +import ( + "sync/atomic" + "testing" +) + +func TestManagerOpenReceiveAvoidsPendingDrainScanTCP(t *testing.T) { + p := newCoreActivityWire(t, false) + var scans atomic.Int64 + p.a.commit(event{testHook: func() { + p.s.beforeInFlightScan = func() { scans.Add(1) } + }}, newProtocolDelivery()) + write(t, p.c, watchdogRequest(t)) + _ = read(t, p.c) + p.fence(t) + if got := scans.Load(); got != 0 { + t.Fatalf("Open receive scanned pending drain set %d times", got) + } +} diff --git a/diam/peer/detached_answer_diagnostics_test.go b/diam/peer/detached_answer_diagnostics_test.go new file mode 100644 index 0000000..eca66cf --- /dev/null +++ b/diam/peer/detached_answer_diagnostics_test.go @@ -0,0 +1,256 @@ +package peer + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/logtest" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +type detachedAnswerWire struct { + *coreActivityWire + records *logtest.Recorder + peers chan PeerEvent +} + +func newDetachedAnswerWire(t *testing.T, dialed bool) *detachedAnswerWire { + t.Helper() + p := &detachedAnswerWire{coreActivityWire: &coreActivityWire{clock: &fakeClock{}, events: make(chan diam.ProtocolErrorEvent, 32), dialed: dialed}, records: logtest.New(), peers: make(chan PeerEvent, 64)} + settings := testSettings("local.example.net") + settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { p.events <- e } + var err error + p.m, err = New(Config{Settings: settings, Logger: p.records.Logger(), Clock: p.clock, OnPeerEvent: func(e PeerEvent) { p.peers <- e }, Timers: Timers{TwInit: time.Hour}}) + if err != nil { + t.Fatal(err) + } + peer := PeerConfig{Host: "known.example.net", NoAutoReconnect: true} + var srv *diam.Server + if dialed { + p.listener, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = p.listener.Close() }) + peer.Endpoints = []Endpoint{{Network: "tcp", Address: p.listener.Addr().String()}} + } + if err = p.m.AddPeer(peer); err != nil { + t.Fatal(err) + } + if dialed { + if err = p.m.Start(context.Background()); err != nil { + t.Fatal(err) + } + } else { + p.listener, srv = startReceiverWithHandlers(t, p.m, 8) + } + t.Cleanup(func() { + if p.c != nil { + _ = p.c.Close() + } + closeManager(t, p.m, srv) + }) + p.connect(t) + return p +} + +// Hold the actor while the native TCP handler admits an answer through ingress. +// Taking and replacing the already-enqueued events observes that admission; +// no protocol event is processed by the test and the actor remains the executor. +func detachedAnswerQueued(t *testing.T, p *detachedAnswerWire, answer *diam.Message, release chan struct{}) event { + t.Helper() + write(t, p.c, answer) + var queued []event + for { + select { + case e := <-p.a.events: + queued = append(queued, e) + if e.kind == wireEvent && e.s == p.s && e.msg.Header.CommandCode == answer.Header.CommandCode { + if e.delivery == nil || e.receiveSequence == 0 { + t.Fatal("TCP answer bypassed ordered ingress") + } + t.Logf("ingress answer command=%d application=%d hop=%08x end=%08x sequence=%d received_at=%s", e.msg.Header.CommandCode, e.msg.Header.ApplicationID, e.msg.Header.HopByHopID, e.msg.Header.EndToEndID, e.receiveSequence, e.receivedAt.UTC().Format(time.RFC3339Nano)) + for _, held := range queued { + p.a.events <- held + } + return e + } + case <-time.After(5 * time.Second): + close(release) + t.Fatal("TCP answer did not reach the actor queue") + return event{} + } + } +} + +const detachedAnswerWarning = "peer: answer on retired connection generation (RFC 6733 §6.2.1)" + +func detachedAnswerWarnings(p *detachedAnswerWire) []slog.Record { + var found []slog.Record + for _, r := range p.records.Records() { + if fmt.Sprint(logtest.Attr(r, "error").Any()) == detachedAnswerWarning { + found = append(found, r) + } + } + return found +} +func detachedAnswerPeerBarrier(t *testing.T, p *detachedAnswerWire) []PeerEvent { + t.Helper() + marker := errors.New("peer observer completion") + p.m.notify(PeerEvent{Reason: marker}) + var found []PeerEvent + for { + select { + case e := <-p.peers: + if e.Reason == marker { + return found + } + if e.Reason != nil && e.Reason.Error() == detachedAnswerWarning { + found = append(found, e) + } + case <-time.After(5 * time.Second): + t.Fatal("peer observer did not finish diagnostics") + return nil + } + } +} + +// A local failure or disconnect timeout detaches the session after the answer has +// entered ingress. The queued answer can report its stale generation, but may +// neither complete the old request successfully nor restore the closed peer. +func TestManagerDetachedQueuedAnswerReportsStaleGenerationTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, closing := range []string{"local-failure", "local-DPR", "received-DPR"} { + t.Run(fmt.Sprintf("dialed=%t/%s", dialed, closing), func(t *testing.T) { + p := newDetachedAnswerWire(t, dialed) + request, result := closingPending(t, p.m, p.c) + switch closing { + case "local-DPR": + p.a.post(event{kind: stop}) + if request := read(t, p.c); request.Header.CommandCode != diam.DisconnectPeer { + t.Fatal("local stop did not send DPR") + } + awaitState(t, p.m, Closing) + case "received-DPR": + closingStart(t, p.m, p.c, false, diam.UnableToComply) + } + actorDeliveryFence(t, p.a) + entered, release, closed := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var beforePhase protocol.Phase + p.a.post(event{testHook: func() { + close(entered) + <-release + if closing == "local-failure" { + p.a.failLocal("receive", io.ErrClosedPipe) + } else { + p.a.handle(event{kind: timeout}) + } + beforePhase = p.s.protocolSnapshot().Phase() + t.Logf("local close committed at=%s phase=%v", time.Now().UTC().Format(time.RFC3339Nano), beforePhase) + close(closed) + }}) + actorDeliveryWait(t, entered, "actor gate") + answer := request.Answer(diam.Success) + queued := detachedAnswerQueued(t, p, answer, release) + close(release) + actorDeliveryWait(t, closed, "local timeout") + actorDeliveryWait(t, queued.delivery.done, "detached answer decision") + actorDeliveryFence(t, p.a) + var got sendResult + select { + case got = <-result: + case <-time.After(5 * time.Second): + t.Fatal("local close did not finish pending request") + } + if !errors.Is(got.err, ErrFailover) || got.answer != nil { + t.Fatalf("queued detached answer completed the request: %+v", got) + } + if p.m.Peers()[0].State != Closed || p.m.Peers()[0].Eligible || beforePhase != protocol.Closed || p.s.protocolSnapshot().Phase() != beforePhase { + t.Fatal("detached answer revived closed state") + } + logs := detachedAnswerWarnings(p) + if len(logs) != 1 { + t.Fatalf("detached application answer stale-generation warnings=%d; want one", len(logs)) + } + record := logs[0] + if record.Level != slog.LevelWarn || record.Message != "peer: protocol error" || fmt.Sprintf("%T", logtest.Attr(record, "error").Any()) != "*errors.errorString" || logtest.Attr(record, "message.command_code").Uint64() != 272 { + t.Fatalf("stale-generation diagnostic changed: %+v", record) + } + var notice diam.ProtocolErrorEvent + for notice.Err == nil || notice.Err.Kind() != diam.ProtocolUnexpectedMessage { + select { + case notice = <-p.events: + case <-time.After(5 * time.Second): + t.Fatal("detached answer lost ProtocolUnexpectedMessage") + } + } + expected := *answer.Header + expected.MessageLength = uint32(answer.Len()) + protocolErrorCheck(t, notice, p.s.c, expected, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0, 0) + peers := detachedAnswerPeerBarrier(t, p) + if len(peers) != 1 || peers[0].Peer.State != Closed || peers[0].Peer.Eligible || fmt.Sprintf("%T", peers[0].Reason) != "*errors.errorString" { + t.Fatalf("stale-generation peer reasons=%+v", peers) + } + }) + } + } +} + +func TestManagerRetiredQueuedAnswerRemainsSilentTCP(t *testing.T) { + p := newDetachedAnswerWire(t, false) + request, result := closingPending(t, p.m, p.c) + actorDeliveryFence(t, p.a) + entered, release := make(chan struct{}), make(chan struct{}) + p.a.post(event{testHook: func() { close(entered); <-release }}) + actorDeliveryWait(t, entered, "actor gate") + queued := detachedAnswerQueued(t, p, request.Answer(diam.Success), release) + retired := make(chan error, 1) + go func() { retired <- p.m.HandleRetire(p.s.c, diam.SCTPAssociationRestart) }() + // The dedicated mailbox has accepted the real retirement before the actor + // resumes. Its priority over ordinary events seals this generation first. + var command retirementCommand + select { + case command = <-p.a.control: + case <-time.After(5 * time.Second): + close(release) + t.Fatal("retirement did not enter its mailbox") + } + p.a.control <- command + close(release) + select { + case err := <-retired: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("retirement did not complete") + } + actorDeliveryWait(t, queued.delivery.done, "retired answer disposition") + actorDeliveryFence(t, p.a) + select { + case got := <-result: + if !errors.Is(got.err, ErrFailover) || got.answer != nil { + t.Fatalf("retired answer completed pending request: %+v", got) + } + case <-time.After(5 * time.Second): + t.Fatal("retirement did not finish pending request") + } + if !p.s.isRetired() || p.s.protocolSnapshot().Phase() != protocol.Retired || p.m.Peers()[0].State != Closed { + t.Fatal("retired answer changed generation state") + } + if len(detachedAnswerWarnings(p)) != 0 || len(detachedAnswerPeerBarrier(t, p)) != 0 { + t.Fatal("retired answer produced ordinary stale-generation diagnostics") + } + select { + case e := <-p.events: + t.Fatalf("retired answer generated protocol event: %+v", e) + default: + } +} diff --git a/diam/peer/discovery.go b/diam/peer/discovery.go index bd96db6..87c8440 100644 --- a/diam/peer/discovery.go +++ b/diam/peer/discovery.go @@ -15,6 +15,7 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/avp" "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) @@ -574,6 +575,9 @@ func (a *actor) closeOwnedSessions() { case e := <-a.events: if e.s != nil { e.s.close() + if e.kind == coreEvent && e.input.Kind == protocol.WriteResult { + e.s.terminalProtocol(e.input) + } } if e.delivery != nil { e.delivery.finish() diff --git a/diam/peer/dwa_diagnostic_contract_test.go b/diam/peer/dwa_diagnostic_contract_test.go new file mode 100644 index 0000000..dd3a72f --- /dev/null +++ b/diam/peer/dwa_diagnostic_contract_test.go @@ -0,0 +1,90 @@ +package peer + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" +) + +// Grammar diagnostics are independent of exchange ownership. Only an owned +// answer can report its unsuccessful Result-Code as rejection of a local DWR. +func TestManagerDWAOwnershipPreservesDiagnosticsTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, phase := range []string{"unsolicited", "current", "stray", "completed"} { + for _, fault := range []string{"grammar", "header", "unsuccessful"} { + t.Run(fmt.Sprintf("dialed=%t/%s/%s", dialed, phase, fault), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + probe := watchdogRequest(t) + if phase != "unsolicited" { + p.expire(t) + probe = read(t, p.c) + if probe.Header.CommandCode != diam.DeviceWatchdog || probe.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("expected local DWR, got %+v", probe.Header) + } + } + answer, err := base.BuildDWA(probe, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + if phase == "completed" { + write(t, p.c, answer) + write(t, p.c, watchdogRequest(t)) + _ = read(t, p.c) + p.fence(t) + if p.state(t).pending || p.s.requests.HasPending() { + t.Fatal("successful DWA did not finish the original probe") + } + } + if phase == "stray" { + answer.Header.HopByHopID++ + } + kind, result := diam.ProtocolInvalidDWA, uint32(diam.MissingAVP) + switch fault { + case "grammar": + answer.AVP = nil + case "header": + answer.Header.CommandFlags |= diam.ProxiableFlag + kind, result = diam.ProtocolInvalidHeader, diam.InvalidHDRBits + case "unsuccessful": + field, err := answer.FindAVP(avp.ResultCode, 0) + if err != nil { + t.Fatal(err) + } + field.Data = datatype.Unsigned32(diam.UnableToComply) + result = 0 + if phase == "current" { + kind, result = diam.ProtocolRejectedDWA, diam.UnableToComply + } + } + before := p.state(t) + write(t, p.c, answer) + notice := protocolErrorReceive(t, p.events) + expected := *answer.Header + expected.MessageLength = uint32(answer.Len()) + protocolErrorCheck(t, notice, p.s.c, expected, kind, result, false, diam.ProtocolReplyNone, 0, 0) + p.fence(t) + after := p.state(t) + if after.pending != before.pending || after.health != before.health || after.num != before.num { + t.Fatalf("invalid or unowned DWA changed watchdog credit: before=%+v after=%+v", before, after) + } + // This reply fences the wire and proves that the DWA selected no answer. + request := watchdogRequest(t) + write(t, p.c, request) + next := read(t, p.c) + if next.Header.CommandCode != request.Header.CommandCode || next.Header.HopByHopID != request.Header.HopByHopID || code(t, next) != diam.Success { + t.Fatalf("DWA selected a reply or disturbed the next DWR: %+v", next.Header) + } + select { + case extra := <-p.events: + t.Fatalf("duplicate DWA notification: %+v", extra) + default: + } + }) + } + } + } +} diff --git a/diam/peer/election_reply_delivery_test.go b/diam/peer/election_reply_delivery_test.go new file mode 100644 index 0000000..5c72d3c --- /dev/null +++ b/diam/peer/election_reply_delivery_test.go @@ -0,0 +1,198 @@ +package peer + +import ( + "context" + "errors" + "io" + "net" + "sync" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" +) + +type heldCEAWriteConn struct { + net.Conn + entered chan struct{} + release <-chan struct{} + once sync.Once +} + +func (c *heldCEAWriteConn) Write(b []byte) (int, error) { + if len(b) >= 20 && b[4]&diam.RequestFlag == 0 && uint32(b[5])<<16|uint32(b[6])<<8|uint32(b[7]) == diam.CapabilitiesExchange { + c.once.Do(func() { close(c.entered) }) + <-c.release + return 0, io.ErrClosedPipe + } + return c.Conn.Write(b) +} + +type heldCEAWriteListener struct { + net.Listener + entered chan struct{} + release <-chan struct{} +} + +func (l *heldCEAWriteListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return &heldCEAWriteConn{Conn: c, entered: l.entered, release: l.release}, nil +} + +func actorDeliveryWait(t *testing.T, done <-chan struct{}, what string) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for %s", what) + } +} +func actorDeliveryFence(t *testing.T, a *actor) { + t.Helper() + done := make(chan struct{}) + go func() { a.commit(event{}, newProtocolDelivery()); close(done) }() + actorDeliveryWait(t, done, "actor completion") +} + +// The CEA is accepted by the session queue and reaches a real transport write +// before its controlled failure. Election completion is an actor-owned event; +// its delivery must remain usable even when admission generated no notices. +func TestManagerElectionCEAWriteFailureNotifiesTCP(t *testing.T) { + for _, loss := range []string{"dial-failure", "initiator-loss"} { + t.Run(loss, func(t *testing.T) { + releaseDial := make(chan struct{}) + var dialOnce sync.Once + unblockDial := func() { dialOnce.Do(func() { close(releaseDial) }) } + releaseWrite := make(chan struct{}) + var writeOnce sync.Once + unblockWrite := func() { writeOnce.Do(func() { close(releaseWrite) }) } + releaseObserver := make(chan struct{}) + var observerOnce sync.Once + unblockObserver := func() { observerOnce.Do(func() { close(releaseObserver) }) } + t.Cleanup(unblockDial) + t.Cleanup(unblockWrite) + t.Cleanup(unblockObserver) + enteredWrite, enteredObserver := make(chan struct{}), make(chan struct{}) + events := make(chan diam.ProtocolErrorEvent, 8) + settings := testSettings("aaa.example.net") + settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { + events <- e + if e.Reply == diam.ProtocolReplyFailed { + close(enteredObserver) + <-releaseObserver + } + } + cfg := Config{Settings: settings, Clock: &fakeClock{}} + endpoint := Endpoint{Network: "tcp", Address: "127.0.0.1:1"} + var outgoing net.Listener + var initiator net.Conn + if loss == "dial-failure" { + cfg.Dial = func(ctx context.Context, _ Endpoint) (net.Conn, error) { + select { + case <-releaseDial: + return nil, errors.New("outbound dial failed") + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } else { + var err error + outgoing, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = outgoing.Close() }) + _ = outgoing.(*net.TCPListener).SetDeadline(time.Now().Add(5 * time.Second)) + endpoint.Address = outgoing.Addr().String() + } + m, err := New(cfg) + if err != nil { + t.Fatal(err) + } + if err = m.AddPeer(PeerConfig{Host: "known.example.net", NoAutoReconnect: true, Endpoints: []Endpoint{endpoint}}); err != nil { + t.Fatal(err) + } + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + listener := &heldCEAWriteListener{Listener: raw, entered: enteredWrite, release: releaseWrite} + srv := &diam.Server{Dict: m.dictionary(), MaxConcurrentHandlers: 8} + if err = m.BindServer(srv); err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(listener) }() + t.Cleanup(func() { + unblockDial() + unblockWrite() + unblockObserver() + if initiator != nil { + _ = initiator.Close() + } + closeManager(t, m, srv) + }) + if err = m.Start(context.Background()); err != nil { + t.Fatal(err) + } + if loss == "initiator-loss" { + initiator, err = outgoing.Accept() + if err != nil { + t.Fatal(err) + } + if cer := read(t, initiator); cer.Header.CommandCode != diam.CapabilitiesExchange { + t.Fatal("initiator did not send CER") + } + awaitState(t, m, WaitICEA) + } else { + awaitState(t, m, WaitConnAck) + } + incoming, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = incoming.Close() }) + cer := capabilitiesExchangeRequest(t) + write(t, incoming, cer) + held := WaitConnAckElect + if loss == "initiator-loss" { + held = WaitReturns + } + awaitState(t, m, held) + m.mu.RLock() + a := m.peers["known.example.net"] + m.mu.RUnlock() + var responder *session + a.commit(event{testHook: func() { responder = a.r }}, newProtocolDelivery()) + if responder == nil { + t.Fatal("election lost its accepted candidate") + } + if loss == "dial-failure" { + unblockDial() + } else { + _ = initiator.Close() + } + actorDeliveryWait(t, enteredWrite, "accepted CEA transport write") + t.Logf("CEA write held before bytes command=%d hop=%08x end=%08x at=%s", cer.Header.CommandCode, cer.Header.HopByHopID, cer.Header.EndToEndID, time.Now().UTC().Format(time.RFC3339Nano)) + actorDeliveryFence(t, a) + unblockWrite() + actorDeliveryWait(t, enteredObserver, "required CEA failure observer") + notice := <-events + expected := *cer.Header + expected.MessageLength = uint32(cer.Len()) + protocolErrorCheck(t, notice, responder.c, expected, diam.ProtocolReplyFailure, 0, true, diam.ProtocolReplyFailed, diam.Success, 0) + // A blocked observer must not own the actor which selected the CEA. + actorDeliveryFence(t, a) + unblockObserver() + correlationEOF(t, incoming) + actorDeliveryFence(t, a) + select { + case extra := <-events: + t.Fatalf("duplicate CEA failure notification: %+v", extra) + default: + } + }) + } +} diff --git a/diam/peer/error_answer_build_failure_test.go b/diam/peer/error_answer_build_failure_test.go new file mode 100644 index 0000000..e10c7a2 --- /dev/null +++ b/diam/peer/error_answer_build_failure_test.go @@ -0,0 +1,199 @@ +package peer + +import ( + "errors" + "fmt" + "log/slog" + "net" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/dict" + "github.com/gomaja/go-diameter/diam/internal/logtest" +) + +type errorAnswerBuildWire struct { + m *Manager + c net.Conn + s *session + a *actor + events chan diam.ProtocolErrorEvent + peers chan PeerEvent +} + +func newErrorAnswerBuildWire(t *testing.T, open bool) *errorAnswerBuildWire { + t.Helper() + p := &errorAnswerBuildWire{events: make(chan diam.ProtocolErrorEvent, 16), peers: make(chan PeerEvent, 32)} + settings := testSettings("local.example.net") + settings.ValidateRequests = true + settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { p.events <- e } + var err error + p.m, err = New(Config{Settings: settings, Clock: &fakeClock{}, OnPeerEvent: func(e PeerEvent) { p.peers <- e }}) + if err != nil { + t.Fatal(err) + } + if err = p.m.AddPeer(PeerConfig{Host: "known.example.net", NoAutoReconnect: true}); err != nil { + t.Fatal(err) + } + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + accepted := make(chan diam.Conn, 1) + srv := &diam.Server{Dict: p.m.dictionary(), MaxConcurrentHandlers: 8, OnNewConnection: func(c diam.Conn) { accepted <- c }} + if err = p.m.BindServer(srv); err != nil { + t.Fatal(err) + } + go func() { _ = srv.Serve(l) }() + t.Cleanup(func() { + if p.c != nil { + _ = p.c.Close() + } + closeManager(t, p.m, srv) + }) + p.c, err = net.Dial("tcp", l.Addr().String()) + if err != nil { + t.Fatal(err) + } + native := renewalScriptReceive(t, accepted, "native TCP acceptance") + p.s = p.m.getSession(native) + if p.s == nil { + t.Fatal("accepted TCP session not registered") + } + if open { + write(t, p.c, capabilitiesExchangeRequest(t)) + if code(t, read(t, p.c)) != diam.Success { + t.Fatal("initial CER rejected") + } + awaitState(t, p.m, ROpen) + p.a, _ = p.s.binding() + p.a.commit(event{}, newProtocolDelivery()) + } + return p +} + +// The observer queue barrier completes earlier nonnil reasons without assuming +// when native EOF cleanup will enqueue its separate transport-closed notice. +func (p *errorAnswerBuildWire) reasons(t *testing.T) []PeerEvent { + t.Helper() + marker := errors.New("observer completion") + p.m.notify(PeerEvent{Reason: marker}) + var events []PeerEvent + for { + event := renewalScriptReceive(t, p.peers, "peer observer completion") + if event.Reason == marker { + return events + } + if event.Reason != nil { + events = append(events, event) + } + } +} + +// The session and its handshake use real TCP. The empty request dictionary is +// supplied through public callbacks: dictionary pointers do not travel on wire. +// It makes the automatic answer builder fail without changing Manager settings. +func TestManagerErrorAnswerBuildFailurePreservesDiagnosticsTCP(t *testing.T) { + cases := []struct { + name string + open, callback bool + command uint32 + }{ + {"pre-CER-callback", false, true, diam.CapabilitiesExchange}, + {"pre-CER-decoded-missing-AVP", false, false, diam.CapabilitiesExchange}, + {"Open-CER-callback", true, true, diam.CapabilitiesExchange}, + {"Open-decoded-malformed-DWR", true, false, diam.DeviceWatchdog}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + records := publicErrorLog(t) + p := newErrorAnswerBuildWire(t, tc.open) + setup := p.reasons(t) + if tc.open { + if len(setup) != 1 || setup[0].Peer.State != ROpen || setup[0].Reason.Error() != "peer: initial connection established" { + t.Fatalf("initial connection reasons=%+v", setup) + } + } else if len(setup) != 0 { + t.Fatalf("unexpected setup reasons=%+v", setup) + } + start := len(records.Records()) + empty, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + message := diam.NewMessage(tc.command, diam.RequestFlag, 0, 11, 12, empty) + kind, result := diam.ProtocolInvalidRequest, uint32(diam.MissingAVP) + failureLog := "peer: request rejection answer failed" + if tc.callback { + kind, result = diam.ProtocolMalformedMessage, diam.InvalidAVPLength + failureLog = "peer: error answer failed; closing connection" + err = p.m.HandleMessageError(p.s.c, message, &diam.MessageError{ResultCode: result, Fatal: true, Err: errors.New("callback decode failure")}) + if err != nil { + t.Fatal(err) + } + } else { + p.m.ServeDIAM(p.s.c, message) + } + notice := protocolErrorReceive(t, p.events) + protocolErrorCheck(t, notice, p.s.c, *message.Header, kind, result, true, diam.ProtocolReplyFailed, result) + correlationEOF(t, p.c) + if p.a != nil { + p.a.commit(event{}, newProtocolDelivery()) + } + grammarText := "diameter message error 5005: Diameter validation failed (5005): required AVP missing" + builderText := fmt.Sprintf("cannot form valid Diameter error answer: Diameter validation failed (3001): application 0: command code %d: dictionary entry not found", tc.command) + type diagnostic struct { + level slog.Level + message, errorType, text string + } + expected := []diagnostic{{slog.LevelError, failureLog, "*fmt.wrapError", builderText}} + if !tc.callback { + expected = append([]diagnostic{{slog.LevelWarn, "peer: protocol error", "*diam.MessageError", grammarText}}, expected...) + } + logs := records.Records()[start:] + if len(logs) != len(expected) { + t.Fatalf("logs=%+v; want %d ordered records", logs, len(expected)) + } + host := "" + if tc.open { + host = "known.example.net" + } + for i, want := range expected { + record := logs[i] + cause := logtest.Attr(record, "error").Any() + if record.Level != want.level || record.Message != want.message || fmt.Sprintf("%T", cause) != want.errorType || fmt.Sprint(cause) != want.text || logtest.Attr(record, "peer_host").String() != host || logtest.Attr(record, "message.command_code").Uint64() != uint64(tc.command) { + t.Fatalf("log %d: level=%s message=%q error=%T(%v) host=%v command=%v", i, record.Level, record.Message, cause, cause, logtest.Attr(record, "peer_host"), logtest.Attr(record, "message.command_code")) + } + } + peers := p.reasons(t) + if !tc.open { + if len(peers) != 0 { + t.Fatalf("unbound failure notified peer=%+v", peers) + } + } else { + if len(peers) < len(expected) { + t.Fatalf("peer reasons=%+v; missing ordered diagnostics", peers) + } + for i, want := range expected { + got := peers[i] + if got.Peer.State != ROpen || fmt.Sprintf("%T", got.Reason) != want.errorType || got.Reason.Error() != want.text { + t.Fatalf("peer reason %d: state=%s reason=%T(%v)", i, got.Peer.State, got.Reason, got.Reason) + } + } + for _, got := range peers[len(expected):] { + if got.Peer.State != Closed || fmt.Sprintf("%T", got.Reason) != "*errors.errorString" || got.Reason.Error() != "peer: responder transport closed" { + t.Fatalf("unexpected later peer reason=%+v", got) + } + } + } + if !p.s.c.Closed() { + t.Fatal("answer build failure retained native transport") + } + select { + case extra := <-p.events: + t.Fatalf("duplicate protocol report=%+v", extra) + default: + } + }) + } +} diff --git a/diam/peer/error_wrapping_test.go b/diam/peer/error_wrapping_test.go index ac71be3..a0713ca 100644 --- a/diam/peer/error_wrapping_test.go +++ b/diam/peer/error_wrapping_test.go @@ -36,22 +36,36 @@ func TestControlBuildErrorsPreserveCause(t *testing.T) { t.Fatal(err) } defer closeManager(t, m, nil) - // Inject invalid local address data after configuration validation. - m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} c := newFakeConn() defer c.Close() s := &session{m: m, c: c, writes: make(chan writeRequest, 8), closed: make(chan struct{})} - a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: IOpen, i: s, active: s, events: make(chan event, 8)} + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: IOpen, i: s, active: s, events: make(chan event, 8), done: make(chan struct{})} + s.actor = a + if kind != iAck { + reportingOpen(t, s) + } + for len(m.callbackQ) > 0 { + <-m.callbackQ + } + // Invalid local address data reaches the actual CER builder. + m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} if kind == iAck { a.state = WaitConnAck } - msg := diam.NewRequest(diam.DeviceWatchdog, 0, newTestDictionary()) + msg := watchdogRequest(t) if kind != iAck { for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{msg: msg} } } - a.step(kind, s, msg) + switch kind { + case iAck: + reportingInitiator(t, a, s, false) + case iDWR: + a.onWire(event{s: s, msg: msg}) + case stop: + a.stop(0) + } select { case e := <-reports: if e.Reason == nil || kind == iAck && errors.Unwrap(e.Reason) == nil || kind != iAck && !strings.Contains(e.Reason.Error(), "queue full") { diff --git a/diam/peer/failure_reporting_test.go b/diam/peer/failure_reporting_test.go index e0db504..8ed5c23 100644 --- a/diam/peer/failure_reporting_test.go +++ b/diam/peer/failure_reporting_test.go @@ -21,8 +21,8 @@ import ( func failureReportingActor(t *testing.T) (*actor, *session, *logtest.Recorder) { t.Helper() records := logtest.New() - m := &Manager{cfg: Config{Settings: testSettings("local.example.net"), Logger: records.Logger(), Clock: &fakeClock{}}, callbackQ: make(chan PeerEvent, 32), controls: make(map[*session]map[uint32]struct{})} - a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: Closed, watchdog: WatchdogInitial} + m := &Manager{cfg: Config{Settings: testSettings("local.example.net"), Logger: records.Logger(), Clock: &fakeClock{}, Timers: Timers{CER: time.Second, Closing: time.Second, TwInit: 6 * time.Second}}, callbackQ: make(chan PeerEvent, 32), controls: make(map[*session]map[uint32]struct{})} + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: Closed, watchdog: WatchdogInitial, events: make(chan event, 64), done: make(chan struct{})} s := &session{m: m, c: newFakeConn(), actor: a, gen: 1, writes: make(chan writeRequest, 8), closed: make(chan struct{})} a.publish(nil) t.Cleanup(s.close) @@ -52,14 +52,11 @@ func assertFailureEvents(t *testing.T, a *actor, states []PeerState, watchdogs [ func TestActorFailureEventSequences(t *testing.T) { t.Run("rejected CEA", func(t *testing.T) { a, s, _ := failureReportingActor(t) - a.state, a.i = WaitICEA, s - a.publish(nil) - cer := reviewCER(t) + cer := reportingInitiator(t, a, s, true) cea, err := base.BuildCEA(cer, testBase("known.example.net"), diam.UnknownPeer) if err != nil { t.Fatal(err) } - s.cerHop, s.cerEnd = cea.Header.HopByHopID, cea.Header.EndToEndID a.onWire(event{s: s, msg: cea}) assertFailureEvents(t, a, []PeerState{Closed, Closed}, []WatchdogState{WatchdogInitial, WatchdogInitial}, []string{"", "invalid CEA"}) }) @@ -67,6 +64,7 @@ func TestActorFailureEventSequences(t *testing.T) { a, s, _ := failureReportingActor(t) <-a.m.callbackQ a.state, a.i, a.active, a.watchdog, a.everOpen = IOpen, s, s, WatchdogSuspect, true + reportingOpen(t, s) a.publish(nil) <-a.m.callbackQ a.watchdogTick() @@ -94,8 +92,6 @@ func assertFailureRecord(t *testing.T, records *logtest.Recorder, level slog.Lev } func TestActorFailureAttributionAndLevels(t *testing.T) { - testDict := newTestDictionary(creditcontrol.Dictionary()) - t.Run("without session", func(t *testing.T) { a, _, records := failureReportingActor(t) a.fail(errors.New("peer: state timeout")) @@ -126,6 +122,22 @@ func TestActorFailureAttributionAndLevels(t *testing.T) { a, s, records := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s var cause error + if failure == "build" { + a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} + } + var candidate *diam.Message + switch operation { + case "CER": + s.inbound = false + a.state = WaitConnAck + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + case "CEA": + candidate = reportingCandidate(t, a, s) + default: + reportingOpen(t, s) + } if failure == "send" { for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{} @@ -134,19 +146,18 @@ func TestActorFailureAttributionAndLevels(t *testing.T) { } else { a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} } - requestDict := testDict - req := diam.NewRequest(diam.DeviceWatchdog, 0, requestDict) + req := watchdogRequest(t) switch operation { case "CER": a.state = WaitConnAck a.step(iAck, s, nil) case "CEA": - s.cerRequest = diam.NewRequest(diam.CapabilitiesExchange, 0, requestDict) + s.cerRequest = candidate a.sendCEA(s) case "DWR": a.sendWatchdog() case "DWA": - a.step(iDWR, s, req) + a.onWire(event{s: s, msg: req}) case "DPR": a.step(stop, s, nil) } @@ -163,12 +174,20 @@ func TestManagerLocalFailureLevels(t *testing.T) { t.Run(operation, func(t *testing.T) { a, s, records := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + if operation == "rejected CER answer" || operation == "build rejected CER answer" { + if operation == "build rejected CER answer" { + a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} + } + reportingCandidate(t, a, s) + } else { + reportingOpen(t, s) + } a.publish(nil) a.m.sessions = map[diam.Conn]*session{s.c: s} for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{} } - req := reviewDWR(t) + req := watchdogRequest(t) switch operation { case "DPA answer": dpr, err := base.BuildDPR(testDict, testBase("known.example.net"), 0) @@ -177,12 +196,12 @@ func TestManagerLocalFailureLevels(t *testing.T) { } a.answerDPR(s, dpr) case "rejected CER answer": - a.m.rejectCER(s, reviewCER(t), diam.UnknownPeer, errors.New("unknown peer")) + reportingRejectCandidate(s, s.cerRequest, diam.UnknownPeer, nil) case "build rejected CER answer": a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} - a.m.rejectCER(s, reviewCER(t), diam.UnknownPeer, errors.New("unknown peer")) + reportingRejectCandidate(s, s.cerRequest, diam.UnknownPeer, nil) case "unsupported answer": - a.m.unsupported(s, diam.NewRequest(999, 0, testDict)) + a.onWire(event{s: s, msg: diam.NewRequest(999, 0, testDict)}) case "error answer": a.onWire(event{s: s, msg: req, messageErr: &diam.MessageError{ResultCode: diam.InvalidAVPValue}}) case "ingress": @@ -265,9 +284,10 @@ func TestManagerSendAdmissionFailureRecord(t *testing.T) { func TestSessionDispatchLocalFailureRecord(t *testing.T) { _, s, records := failureReportingActor(t) + reportingOpen(t, s) s.actor = nil s.ingress = make(chan incoming, 1) - s.ingress <- incoming{msg: reviewDWR(t), err: &diam.MessageError{ResultCode: diam.InvalidAVPValue}} + s.ingress <- incoming{msg: watchdogRequest(t), err: &diam.MessageError{ResultCode: diam.InvalidAVPValue}} for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{} } @@ -299,8 +319,9 @@ func TestActorFailureStopsTransitionPublication(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { a, s, _ := failureReportingActor(t) + request := reportingCandidate(t, a, s) a.state, a.r = tc.state, s - s.cerRequest = reviewCER(t) + s.cerRequest = request for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{} } @@ -315,11 +336,17 @@ func TestActorFailureStopsTransitionPublication(t *testing.T) { t.Run("reopen watchdog", func(t *testing.T) { a, s, _ := failureReportingActor(t) <-a.m.callbackQ - a.state, a.i, a.watchdog, a.everOpen = WaitICEA, s, WatchdogDown, true + a.watchdog, a.everOpen = WatchdogDown, true + a.publish(nil) + cer := reportingInitiator(t, a, s, true) + for len(a.m.callbackQ) > 0 { + <-a.m.callbackQ + } for len(s.writes) < cap(s.writes) { s.writes <- writeRequest{} } - a.step(iCEA, s, nil) + cea := correlationCEA(t, cer, "known.example.net") + a.onWire(event{s: s, msg: cea}) assertFailureEvents(t, a, []PeerState{IOpen, Closed, Closed}, []WatchdogState{WatchdogReopen, WatchdogReopen, WatchdogDown}, []string{"recovered connection", "send DWR", "send DWR"}) }) } @@ -354,8 +381,8 @@ func TestActorRefusedDialRecord(t *testing.T) { func TestActorFailedDialUsesFailedEndpointDuringElection(t *testing.T) { a, s, records := failureReportingActor(t) + s.cerRequest = reportingCandidate(t, a, s) a.state, a.r = WaitConnAckElect, s - s.cerRequest = reviewCER(t) destination := &net.TCPAddr{IP: net.IPv4(192, 0, 2, 40), Port: 3868} cause := &net.OpError{Op: "dial", Net: "tcp", Addr: destination, Err: errors.New("refused marker")} a.onDial(event{err: cause}) diff --git a/diam/peer/fatal_repeated_cer_test.go b/diam/peer/fatal_repeated_cer_test.go new file mode 100644 index 0000000..54e2c42 --- /dev/null +++ b/diam/peer/fatal_repeated_cer_test.go @@ -0,0 +1,42 @@ +package peer + +import ( + "fmt" + "testing" + + "github.com/gomaja/go-diameter/diam" +) + +// A direct fatal body-error callback reports its required answer without +// assuming the reader's responsibility for closing the transport. +func TestManagerFatalRepeatedCERRequiresErrorAnswer(t *testing.T) { + for _, dialed := range []bool{false, true} { + t.Run(fmt.Sprintf("dialed=%t", dialed), func(t *testing.T) { + p := newCoreActivityWire(t, dialed) + request := capabilitiesExchangeRequest(t) + description := &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true} + if err := p.m.HandleMessageError(p.s.c, request, description); err != nil { + t.Fatal(err) + } + event := protocolErrorReceive(t, p.events) + protocolErrorCheck(t, event, p.s.c, *request.Header, diam.ProtocolMalformedMessage, diam.InvalidAVPLength, true, diam.ProtocolReplyQueued, diam.InvalidAVPLength) + answer := read(t, p.c) + if answer.Header.CommandCode != diam.CapabilitiesExchange || answer.Header.CommandFlags&diam.RequestFlag != 0 || answer.Header.HopByHopID != request.Header.HopByHopID || answer.Header.EndToEndID != request.Header.EndToEndID || code(t, answer) != diam.InvalidAVPLength { + t.Fatalf("fatal repeated CER error answer changed: %+v", answer) + } + if err := answer.ValidateOutgoing(); err != nil { + t.Fatalf("fatal repeated CER answer grammar: %v", err) + } + probe := watchdogRequest(t) + write(t, p.c, probe) + if next := read(t, p.c); next.Header.CommandCode != diam.DeviceWatchdog || next.Header.HopByHopID != probe.Header.HopByHopID || code(t, next) != diam.Success { + t.Fatalf("fatal callback changed transport ownership: %+v", next) + } + select { + case extra := <-p.events: + t.Fatalf("duplicate fatal repeated CER report: %+v", extra) + default: + } + }) + } +} diff --git a/diam/peer/handshake_regression_test.go b/diam/peer/handshake_regression_test.go index 06771ff..dc32dd8 100644 --- a/diam/peer/handshake_regression_test.go +++ b/diam/peer/handshake_regression_test.go @@ -28,7 +28,7 @@ func (h consumingErrorHandler) HandleMessageError(c diam.Conn, msg *diam.Message _, err = a.WriteTo(c) return err } -func reviewPeerServer(t *testing.T, wrap bool) (*Manager, net.Conn) { +func acceptedPeerServer(t *testing.T, wrap bool) (*Manager, net.Conn) { testDict := newTestDictionary(creditcontrol.Dictionary()) t.Helper() @@ -60,7 +60,7 @@ func reviewPeerServer(t *testing.T, wrap bool) (*Manager, net.Conn) { t.Cleanup(func() { _ = c.Close() }) return m, c } -func reviewCER(t *testing.T) *diam.Message { +func capabilitiesExchangeRequest(t *testing.T) *diam.Message { testDict := newTestDictionary(creditcontrol.Dictionary()) t.Helper() @@ -70,7 +70,7 @@ func reviewCER(t *testing.T) *diam.Message { } return m } -func reviewDWR(t *testing.T) *diam.Message { +func watchdogRequest(t *testing.T) *diam.Message { testDict := newTestDictionary(creditcontrol.Dictionary()) t.Helper() @@ -81,18 +81,18 @@ func reviewDWR(t *testing.T) *diam.Message { return m } func TestConsumedMessageErrorDoesNotStallManager(t *testing.T) { - _, c := reviewPeerServer(t, true) - write(t, c, reviewCER(t)) + _, c := acceptedPeerServer(t, true) + write(t, c, capabilitiesExchangeRequest(t)) if a := read(t, c); code(t, a) != diam.Success { t.Fatal(a) } - bad := reviewDWR(t) + bad := watchdogRequest(t) bad.AddAVP(diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2})) write(t, c, bad) if a := read(t, c); code(t, a) != diam.InvalidAVPLength { t.Fatal(a) } - next := reviewDWR(t) + next := watchdogRequest(t) write(t, c, next) a := read(t, c) if code(t, a) != diam.Success || a.Header.HopByHopID != next.Header.HopByHopID { @@ -102,15 +102,15 @@ func TestConsumedMessageErrorDoesNotStallManager(t *testing.T) { func TestPeerBaseHeaderApplicationOnWire(t *testing.T) { for _, cmd := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { t.Run(fmt.Sprint(cmd), func(t *testing.T) { - manager, c := reviewPeerServer(t, false) + manager, c := acceptedPeerServer(t, false) records := manager.cfg.Logger.Handler().(*logtest.Recorder) - req := reviewCER(t) + req := capabilitiesExchangeRequest(t) if cmd != diam.CapabilitiesExchange { write(t, c, req) if a := read(t, c); code(t, a) != diam.Success { t.Fatal(a) } - req = reviewDWR(t) + req = watchdogRequest(t) req.Header.CommandCode = cmd } req.Header.ApplicationID = 4 @@ -138,7 +138,7 @@ func TestPeerBaseHeaderApplicationOnWire(t *testing.T) { req.Header.CommandFlags = 0 req.AddAVP(diam.NewAVP(avp.ResultCode, avp.Mbit, 0, datatype.Unsigned32(diam.Success))) write(t, c, req) - next := reviewDWR(t) + next := watchdogRequest(t) write(t, c, next) a = read(t, c) if code(t, a) != diam.Success || a.Header.HopByHopID != next.Header.HopByHopID { @@ -167,8 +167,8 @@ func TestPeerBaseHeaderApplicationOnWire(t *testing.T) { func TestPeerCERMissingIdentityOnWire(t *testing.T) { for _, missing := range []uint32{avp.OriginHost, avp.OriginRealm} { t.Run(fmt.Sprint(missing), func(t *testing.T) { - _, c := reviewPeerServer(t, false) - req := reviewCER(t) + _, c := acceptedPeerServer(t, false) + req := capabilitiesExchangeRequest(t) for i, a := range req.AVP { if a.Code == missing { req.AVP = append(req.AVP[:i], req.AVP[i+1:]...) @@ -216,7 +216,10 @@ func testWaitReturnsAnsweringConnection(t *testing.T, malformed bool) { defer s.close() m.wg.Add(1) go s.writer() - a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitReturns, r: s, events: make(chan event, 8)} + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitReturns, r: s, events: make(chan event, 8), done: make(chan struct{})} + s.actor, s.inbound = a, true + a.publish(nil) + renewalCoreSession(t, s, true, false, "example.net") if err = c.other.SetReadDeadline(time.Now().Add(time.Second)); err != nil { t.Fatal(err) } @@ -224,7 +227,7 @@ func testWaitReturnsAnsweringConnection(t *testing.T, malformed bool) { if malformed { me = &diam.MessageError{ResultCode: diam.InvalidAVPLength, FailedAVP: diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2})} } - a.onWire(event{kind: wireEvent, s: s, msg: reviewDWR(t), messageErr: me}) + a.onWire(event{kind: wireEvent, s: s, msg: watchdogRequest(t), messageErr: me}) answer, err := diam.ReadMessage(c.other, testDict) if !errors.Is(err, io.EOF) { t.Fatalf("expected close without a pre-CEA answer: answer=%v err=%v", answer, err) @@ -241,9 +244,13 @@ func TestMalformedCEADecodeErrorPreventsAdmission(t *testing.T) { defer closeManager(t, m, nil) c := newFakeConn() defer c.Close() - req := reviewCER(t) + req := capabilitiesExchangeRequest(t) s := &session{m: m, c: c, gen: 1, cerHop: req.Header.HopByHopID, cerEnd: req.Header.EndToEndID, writes: make(chan writeRequest, 8), closed: make(chan struct{})} - a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitICEA, i: s, events: make(chan event, 8)} + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitICEA, i: s, events: make(chan event, 8), done: make(chan struct{})} + s.actor = a + a.publish(nil) + renewalCoreSession(t, s, true, false, "example.net") + req = s.cerRequest cea, err := base.BuildCEA(req, testBase("known.example.net"), diam.Success) if err != nil { t.Fatal(err) @@ -267,7 +274,7 @@ func TestClosedSessionErrorIsNotQueueFull(t *testing.T) { m.sessions[c] = s s.close() defer m.unregister(s) - msg := reviewDWR(t) + msg := watchdogRequest(t) messageErr := &diam.MessageError{ResultCode: diam.InvalidHDRBits} if err = s.enqueueIncoming(incoming{msg: msg, err: messageErr}); !errors.Is(err, net.ErrClosed) { t.Fatalf("closed ingress error=%v", err) @@ -275,16 +282,14 @@ func TestClosedSessionErrorIsNotQueueFull(t *testing.T) { if err = m.HandleMessageError(c, msg, messageErr); err != nil { t.Fatalf("closed managed connection was not handled: %v", err) } - if err = m.answerMessageError(s, reviewDWR(t), &diam.MessageError{ResultCode: diam.InvalidHDRBits}); !errors.Is(err, net.ErrClosed) { - t.Fatalf("closed write error=%v", err) - } + } func TestPeerInvalidCERAfterHandshakeCloses(t *testing.T) { - _, c := reviewPeerServer(t, false) - write(t, c, reviewCER(t)) + _, c := acceptedPeerServer(t, false) + write(t, c, capabilitiesExchangeRequest(t)) read(t, c) - req := reviewCER(t) + req := capabilitiesExchangeRequest(t) req.Header.ApplicationID = 4 write(t, c, req) a := read(t, c) diff --git a/diam/peer/local_disconnect_cause_test.go b/diam/peer/local_disconnect_cause_test.go new file mode 100644 index 0000000..c9b3098 --- /dev/null +++ b/diam/peer/local_disconnect_cause_test.go @@ -0,0 +1,55 @@ +package peer + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/sm" +) + +// Close passes its uint32 cause through to the DPR. This is API +// compatibility for local callers; RFC 6733 §5.4.3 defines only values 0–2. +func TestManagerClosePreservesLocalDisconnectCause(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, cause := range []uint32{3, ^uint32(0)} { + t.Run(fmt.Sprintf("dialed=%t/cause=%d", dialed, cause), func(t *testing.T) { + m, c, _, _ := closingPeer(t, dialed) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- m.Close(ctx, sm.DisconnectCause(cause)) }() + if err := c.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { + t.Fatal(err) + } + dpr, err := diam.ReadMessage(c, m.dictionary()) + if err != nil { + t.Fatalf("Close(%d) did not send DPR: %v", cause, err) + } + if dpr.Header.CommandCode != diam.DisconnectPeer || dpr.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("Close sent %+v", dpr.Header) + } + value, err := dpr.FindAVP(avp.DisconnectCause, 0) + if err != nil || value.Data != datatype.Enumerated(cause) { + t.Fatalf("Disconnect-Cause=%v, %v; want raw %d", value, err, cause) + } + answer, err := base.BuildDPA(dpr, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + write(t, c, answer) + if err := <-done; err != nil { + t.Fatal(err) + } + if p := m.Peers()[0]; p.State != Closed || p.Eligible { + t.Fatalf("Close did not finish: %+v", p) + } + }) + } + } +} diff --git a/diam/peer/malformed_cea_correlation_test.go b/diam/peer/malformed_cea_correlation_test.go index 85f0cd4..4582ec6 100644 --- a/diam/peer/malformed_cea_correlation_test.go +++ b/diam/peer/malformed_cea_correlation_test.go @@ -97,7 +97,7 @@ func TestManagerMalformedCEACorrelationOnWire(t *testing.T) { if p := m.Peers()[0]; !p.Eligible || p.Realm != "example.net" { t.Fatalf("valid followup not admitted: %+v", p) } - write(t, c, reviewDWR(t)) + write(t, c, watchdogRequest(t)) if answer := read(t, c); answer.Header.CommandCode != diam.DeviceWatchdog || code(t, answer) != diam.Success { t.Fatalf("valid followup connection unusable: %v", answer) } diff --git a/diam/peer/manager.go b/diam/peer/manager.go index 0b3e1aa..9cac7f9 100644 --- a/diam/peer/manager.go +++ b/diam/peer/manager.go @@ -33,10 +33,9 @@ import ( "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/dict" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/internal/watchdog" "github.com/gomaja/go-diameter/diam/sm" - "github.com/gomaja/go-diameter/diam/sm/smparser" - "github.com/gomaja/go-diameter/diam/sm/smpeer" "github.com/gomaja/go-sctp" ) @@ -562,26 +561,7 @@ func (m *Manager) acceptConnection(c diam.Conn) *session { if s == nil { return nil } - s.firstMu.Lock() - s.preTimer = m.cfg.Clock.AfterFunc(m.cfg.Timers.CER, func() { - s.firstMu.Lock() - waiting := !s.first && !s.isRetired() && !s.c.Closed() - if waiting { - s.terminal.Store(true) - } - s.firstMu.Unlock() - if waiting { - m.report(s, nil, errors.New("peer: pre-CER timeout")) - s.close() - m.protocolError(s.c, nil, diam.ProtocolHandshakeTimeout, 0, true, diam.ProtocolReplyNone, 0) - } - }) - select { - case <-s.closed: - s.preTimer.Stop() - default: - } - s.firstMu.Unlock() + return s } func (m *Manager) shutdownConnection(ctx context.Context, c diam.Conn) { @@ -632,76 +612,13 @@ func (m *Manager) processDIAMAt(s *session, msg *diam.Message, receivedAt time.T if s.isRetired() { return } - c := s.c - if s.inbound { - s.firstMu.Lock() - if s.rejectingFirst { - s.firstMu.Unlock() - return - } - first := !s.first - if first { - s.first = true - if s.preTimer != nil { - s.preTimer.Stop() - } - } - s.firstMu.Unlock() - if first { - // RFC 6733 §5.6.1: pre-CER admission accepts only a CER. - if msg.Header.CommandCode != diam.CapabilitiesExchange || msg.Header.CommandFlags&diam.RequestFlag == 0 { - m.report(s, msg, errors.New("peer: first inbound message is not CER")) - s.close() - m.protocolError(c, msg, diam.ProtocolHandshakeState, 0, true, diam.ProtocolReplyNone, 0, delivery...) - return - } - local := m.baseSettings(c) - s.localApps = base.AdvertisedApplicationIDs(local) - if err := base.ValidateMessage(msg); err != nil { - m.rejectBaseMessage(s, msg, err, true, false, delivery...) - return - } - if rejection := base.ValidateRequest(msg, s.localApps, m.cfg.Settings.ValidateRequests, m.cfg.Settings.RejectUnknownMandatoryAVPs); rejection != nil { - m.rejectRequest(s, msg, rejection, true, false, delivery...) - return - } - cer := new(smparser.CER) - _, err := cer.Parse(msg, smparser.ParseOptions{Role: smparser.Server, TLS: c.TLS() != nil, Dictionary: msg.Dictionary(), LocalApplications: s.localApps}) - if err != nil { - code := uint32(diam.UnableToComply) - if errors.Is(err, base.ErrNoCommonApplication) { - code = diam.NoCommonApplication - } - if errors.Is(err, base.ErrNoCommonSecurity) { - code = diam.NoCommonSecurity - } - m.rejectCER(s, msg, code, err, delivery...) - return - } - m.mu.RLock() - a := m.peers[identity(cer.OriginHost)] - m.mu.RUnlock() - if a == nil { // RFC 6733 §5.3: reject an unknown peer after validating its CER. - m.rejectCER(s, msg, diam.UnknownPeer, fmt.Errorf("peer: unknown Origin-Host %s", cer.OriginHost), delivery...) - return - } - if !s.bind(a, 0) { - return - } - e := event{kind: rConnCER, s: s, msg: msg, meta: smpeer.FromCER(cer).Clone(), receivedAt: receivedAt, receiveSequence: sequence} - if len(delivery) > 0 { - a.commit(e, delivery[0]) - } else { - a.post(e) - } - return - } - } a, _ := s.binding() if a == nil { - m.report(s, msg, errors.New("peer: unbound connection message")) - s.close() - m.protocolError(c, msg, diam.ProtocolHandshakeState, 0, true, diam.ProtocolReplyNone, 0, delivery...) + var d *protocolDelivery + if len(delivery) != 0 { + d = delivery[0] + } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: msg, At: receivedAt, Sequence: sequence}, d) return } e := event{kind: wireEvent, s: s, msg: msg, receivedAt: receivedAt, receiveSequence: sequence} @@ -711,46 +628,7 @@ func (m *Manager) processDIAMAt(s *session, msg *diam.Message, receivedAt time.T a.post(e) } } -func (m *Manager) rejectCER(s *session, msg *diam.Message, code uint32, reason error, delivery ...*protocolDelivery) { - delivery, report := m.requiredReplyDelivery(delivery) - defer report() - s.terminal.Store(true) - reply := diam.ProtocolReplyFailed - kind := diam.ProtocolCERRejected - if base.ValidateHeader(msg) != nil { - kind = diam.ProtocolInvalidHeader - } - defer func() { - m.protocolError(s.c, msg, kind, code, true, reply, code, delivery...) - }() - cfg := m.baseSettings(s.c) - var answer *diam.Message - var err error - var messageErr *diam.MessageError - if errors.As(reason, &messageErr) { - code = messageErr.ResultCode - // RFC 6733 §7.1.5, Verified Erratum 4615: one Failed-AVP. - var failed []*diam.AVP - if messageErr.FailedAVP != nil { - failed = []*diam.AVP{messageErr.FailedAVP} - } - answer, err = base.BuildErrorAnswer(msg, cfg, messageErr.ResultCode, failed, messageErr.ResultCode >= 3000 && messageErr.ResultCode < 4000) - } else { - answer, err = base.BuildCEA(msg, cfg, code) - } - if err != nil { - m.reportLocal(s, msg, "peer: build rejected CER answer failed; closing connection", err) - s.close() - return - } - m.report(s, msg, reason) - if err := s.sendRequired(answer, msg, true, code, delivery...); err != nil { - m.reportLocal(s, msg, "peer: send rejected CER answer failed; closing connection", err) - s.close() - } else { - reply = diam.ProtocolReplyQueued - } -} + func (m *Manager) baseSettings(c diam.Conn) base.Settings { cfg := base.Settings{OriginHost: m.cfg.Settings.OriginHost, OriginRealm: m.cfg.Settings.OriginRealm, VendorID: m.cfg.Settings.VendorID, ProductName: m.cfg.Settings.ProductName, OriginStateID: m.cfg.Settings.OriginStateID, FirmwareRevision: m.cfg.Settings.FirmwareRevision, HostIPAddresses: base.CloneAddresses(m.cfg.Settings.HostIPAddresses), SupportedVendorID: m.cfg.Settings.SupportedVendorID, AuthApplicationID: m.cfg.Settings.AuthApplicationID, AcctApplicationID: m.cfg.Settings.AcctApplicationID, VendorSpecificApplicationID: m.cfg.Settings.VendorSpecificApplicationID} // RFC 6733 §5.3.5: only advertise actual possible source addresses. @@ -804,36 +682,6 @@ func (m *Manager) supportsApplication(appID uint32) bool { return ok } -func (m *Manager) unsupported(s *session, msg *diam.Message, delivery ...*protocolDelivery) { - delivery, report := m.requiredReplyDelivery(delivery) - defer report() - // RFC 6733 §§7.1.3, 7.2: unsupported requests receive an E-bit 3007 for - // an application this node does not support, otherwise 3001; answers are - // reported and discarded. - m.report(s, msg, fmt.Errorf("peer: unhandled message %d/%d", msg.Header.ApplicationID, msg.Header.CommandCode)) - if msg.Header.CommandFlags&diam.RequestFlag == 0 { - m.protocolError(s.c, msg, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0, delivery...) - return - } - resultCode := uint32(diam.CommandUnsupported) - kind := diam.ProtocolUnsupportedCommand - if !base.SupportsApplication(s.localApps, msg.Header.ApplicationID) { - resultCode = diam.ApplicationUnsupported - kind = diam.ProtocolUnsupportedApplication - } - answer, err := base.BuildErrorAnswer(msg, m.baseSettings(s.c), resultCode, nil, true) - if err == nil { - err = s.sendRequired(answer, msg, false, resultCode, delivery...) - } - m.reportLocal(s, msg, "peer: unsupported request answer failed", err) - reply := diam.ProtocolReplyQueued - if err != nil { - reply = diam.ProtocolReplyFailed - s.close() - } - m.protocolError(s.c, msg, kind, resultCode, err != nil, reply, resultCode, delivery...) -} - // HandleMessageError takes responsibility for a malformed message by admitting // it to the session's ordered queue or closing a managed connection whose queue // cannot admit it. Completion can occur after this returns; @@ -868,98 +716,5 @@ func (m *Manager) HandleMessageError(c diam.Conn, msg *diam.Message, me *diam.Me return nil } -func (m *Manager) answerMessageError(s *session, msg *diam.Message, me *diam.MessageError, delivery ...*protocolDelivery) (handlingErr error) { - delivery, report := m.requiredReplyDelivery(delivery) - defer report() - c := s.c - if me == nil { - return nil - } - kind := diam.ProtocolMalformedMessage - closing := me.Fatal - reply, replyCode := diam.ProtocolReplyNone, uint32(0) - defer func() { - if handlingErr != nil { - m.reportLocal(s, msg, "peer: error answer failed; closing connection", handlingErr) - s.close() - closing, reply = true, diam.ProtocolReplyFailed - } - m.protocolError(c, msg, kind, me.ResultCode, closing, reply, replyCode, delivery...) - }() - if headerErr := base.ValidateHeader(msg); headerErr != nil && !base.FatalHeaderError(me) { - me = headerErr - kind = diam.ProtocolInvalidHeader - } - // RFC 6733 §7.1.5 and Verified Erratum 4615: carry the offending - // AVP in one Failed-AVP container for payload and framing failures. - var failed []*diam.AVP - if me != nil && me.FailedAVP != nil { - failed = []*diam.AVP{me.FailedAVP} - } - s.firstMu.Lock() - preCER := s.inbound && !s.first - if preCER { - s.first = true - if msg != nil && msg.Header != nil && me != nil && msg.Header.CommandCode == diam.CapabilitiesExchange && msg.Header.CommandFlags&diam.RequestFlag != 0 { - s.rejectingFirst = true - } - if s.preTimer != nil { - s.preTimer.Stop() - s.preTimer = nil - } - } - s.firstMu.Unlock() - if preCER { // RFC 6733 §5.6.1: discard malformed pre-CER traffic and close the connection. - closing = true - s.terminal.Store(true) - if msg == nil || msg.Header == nil || me == nil || msg.Header.CommandCode != diam.CapabilitiesExchange || msg.Header.CommandFlags&diam.RequestFlag == 0 { - kind = diam.ProtocolHandshakeState - m.report(s, msg, fmt.Errorf("peer: malformed pre-CER message; closing connection: %w", me)) - s.close() - return nil - } - replyCode = me.ResultCode - answer, err := base.BuildErrorAnswer(msg, m.baseSettings(c), me.ResultCode, failed, me.ResultCode >= 3000 && me.ResultCode < 4000) - if err != nil { - return err - } - m.report(s, msg, fmt.Errorf("peer: malformed CER rejected; closing connection: %w", me)) - if err := s.sendRequired(answer, msg, true, replyCode, delivery...); err != nil { - return fmt.Errorf("peer: write: %w", err) - } - reply = diam.ProtocolReplyQueued - return nil - } - if msg == nil || me == nil || msg.Header == nil { - return nil - } - if msg.Header.CommandFlags&diam.RequestFlag == 0 { - return nil - } - if !me.Fatal && !base.IsCommand(msg) { - if rejection := base.ValidateRequest(msg, s.localApps, m.cfg.Settings.ValidateRequests, m.cfg.Settings.RejectUnknownMandatoryAVPs, me); rejection != nil { - kind = rejection.Kind - me = &diam.MessageError{ResultCode: rejection.Code, Err: me} - failed = rejection.Failed - } - } - replyCode = me.ResultCode - answer, err := base.BuildErrorAnswer(msg, m.baseSettings(c), me.ResultCode, failed, me.ResultCode >= 3000 && me.ResultCode < 4000) - if err != nil { - return err - } - // RFC 6733 §§2.5, 5.3 and 7.1.3: an invalid CER establishes no capabilities. - closeAfter := msg.Header.CommandCode == diam.CapabilitiesExchange && me.ResultCode == diam.InvalidHDRBits - closing = closing || closeAfter - if closeAfter { - m.report(s, msg, fmt.Errorf("peer: invalid CER rejected; closing connection: %w", me)) - } - if err := s.sendRequired(answer, msg, closeAfter, replyCode, delivery...); err != nil { - return fmt.Errorf("peer: write: %w", err) - } - reply = diam.ProtocolReplyQueued - return nil -} - var _ diam.Handler = (*Manager)(nil) var _ diam.MessageErrorHandler = (*Manager)(nil) diff --git a/diam/peer/message_cost_bench_test.go b/diam/peer/message_cost_bench_test.go new file mode 100644 index 0000000..1935c31 --- /dev/null +++ b/diam/peer/message_cost_bench_test.go @@ -0,0 +1,276 @@ +package peer_test + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "net/netip" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/dict" + "github.com/gomaja/go-diameter/diam/dict/creditcontrol" + "github.com/gomaja/go-diameter/diam/peer" + "github.com/gomaja/go-diameter/diam/sm" +) + +// BenchmarkInboundMessages uses only public Manager APIs and real TCP sockets. +// Manager has no inbound application handler: requests receive 3001, while +// unsolicited answers complete at the synchronous protocol-error observer. +// CorrelatedApplicationAnswer measures the complete Send request/answer exchange, +// including the outbound request, correlation and the peer's response encoding. +// WatchdogExchange measures an inbound DWR and its outbound DWA. Automatic local +// address discovery remains enabled. Setup, CER/CEA and one warmup are untimed. +func BenchmarkInboundMessages(b *testing.B) { + for _, side := range []string{"Accepted", "Dialed"} { + b.Run(side, func(b *testing.B) { + for _, exchange := range []string{"ApplicationRequestRejection", "UnsolicitedApplicationAnswer", "CorrelatedApplicationAnswer", "WatchdogExchange"} { + b.Run(exchange, func(b *testing.B) { benchmarkInboundMessages(b, side, exchange) }) + } + }) + } +} + +func benchmarkInboundMessages(b *testing.B, side, exchange string) { + dictionary, err := dict.New(dict.Base(), creditcontrol.Dictionary()) + if err != nil { + b.Fatal(err) + } + notifications := make(chan diam.ProtocolErrorEvent, 1) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + manager, err := peer.New(peer.Config{ + Settings: sm.Settings{OriginHost: "local.example", OriginRealm: "example", VendorID: 13, + ProductName: "benchmark", Dict: dictionary, + AuthApplicationID: []*diam.AVP{diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))}, + OnProtocolError: func(e diam.ProtocolErrorEvent) { notifications <- e }, + }, + Timers: peer.Timers{TwInit: time.Hour}, Logger: logger, + }) + if err != nil { + b.Fatal(err) + } + remote := messageCostPeer(b, manager, dictionary, logger, side) + request := diam.NewMessage(diam.ReAuth, diam.RequestFlag|diam.ProxiableFlag, 4, 3, 4, dictionary) + request.AddAVP(diam.NewAVP(avp.SessionID, avp.Mbit, 0, datatype.UTF8String("benchmark;1"))) + host, destination := "peer.example", "local.example" + if exchange == "CorrelatedApplicationAnswer" { + host, destination = destination, host + } + addMessageCostIdentity(request, host) + request.AddAVP(diam.NewAVP(avp.DestinationRealm, avp.Mbit, 0, datatype.DiameterIdentity("example"))) + request.AddAVP(diam.NewAVP(avp.DestinationHost, avp.Mbit, 0, datatype.DiameterIdentity(destination))) + request.AddAVP(diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))) + request.AddAVP(diam.NewAVP(avp.ReAuthRequestType, avp.Mbit, 0, datatype.Enumerated(0))) + message := request + switch exchange { + case "UnsolicitedApplicationAnswer": + message = request.Answer(diam.Success) + message.AddAVP(diam.NewAVP(avp.SessionID, avp.Mbit, 0, datatype.UTF8String("benchmark;1"))) + addMessageCostIdentity(message, "peer.example") + case "WatchdogExchange": + message = diam.NewMessage(diam.DeviceWatchdog, diam.RequestFlag, 0, 3, 4, dictionary) + addMessageCostIdentity(message, "peer.example") + } + wire, err := message.Serialize() + if err != nil { + b.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + var responderDone chan error + if exchange == "CorrelatedApplicationAnswer" { + responderDone = make(chan error, 1) + go func() { + for { + incoming, err := diam.ReadMessage(remote, dictionary) + if err != nil { + responderDone <- err + return + } + answer := incoming.Answer(diam.Success) + answer.AddAVP(diam.NewAVP(avp.SessionID, avp.Mbit, 0, datatype.UTF8String("benchmark;1"))) + addMessageCostIdentity(answer, "peer.example") + if _, err := answer.WriteTo(remote); err != nil { + responderDone <- err + return + } + } + }() + defer func() { _ = remote.Close(); <-responderDone }() + } + exchangeMessage := func() { + if exchange == "CorrelatedApplicationAnswer" { + answer, err := manager.Send(ctx, request) + if err != nil { + b.Fatal(err) + } + messageCostResult(b, answer, diam.Success) + if answer.Header.CommandCode != request.Header.CommandCode || answer.Header.CommandFlags&diam.RequestFlag != 0 { + b.Fatalf("unexpected correlated answer: %+v", answer.Header) + } + return + } + if n, err := remote.Write(wire); err != nil || n != len(wire) { + b.Fatalf("write = %d, %v; want %d bytes", n, err, len(wire)) + } + if exchange != "UnsolicitedApplicationAnswer" { + answer, err := diam.ReadMessage(remote, dictionary) + if err != nil { + b.Fatal(err) + } + if answer.Header.CommandCode != message.Header.CommandCode || answer.Header.CommandFlags&diam.RequestFlag != 0 || + answer.Header.HopByHopID != message.Header.HopByHopID || answer.Header.EndToEndID != message.Header.EndToEndID { + b.Fatalf("unexpected answer: %+v", answer.Header) + } + result := uint32(diam.Success) + if exchange == "ApplicationRequestRejection" { + result = diam.CommandUnsupported + } + messageCostResult(b, answer, result) + } + if exchange == "ApplicationRequestRejection" || exchange == "UnsolicitedApplicationAnswer" { + select { + case event := <-notifications: + if !event.Message.HasHeader || event.Message.Header.CommandCode != message.Header.CommandCode || + event.Message.Header.HopByHopID != message.Header.HopByHopID || event.Message.Header.EndToEndID != message.Header.EndToEndID { + b.Fatalf("unexpected protocol notification: %+v", event) + } + case <-ctx.Done(): + b.Fatal("protocol notification: ", ctx.Err()) + } + } + } + exchangeMessage() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + exchangeMessage() + } + b.StopTimer() +} + +func messageCostResult(b *testing.B, message *diam.Message, want uint32) { + b.Helper() + result, err := message.FindAVP(avp.ResultCode, 0) + if err != nil || result.Data != datatype.Unsigned32(want) { + b.Fatalf("Result-Code = %v, %v; want %d", result, err, want) + } +} + +func messageCostPeer(b *testing.B, manager *peer.Manager, dictionary *dict.Parser, logger *slog.Logger, side string) net.Conn { + b.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = listener.Close() }) + config := peer.PeerConfig{Host: "peer.example", NoAutoReconnect: true} + if side == "Dialed" { + config.Endpoints = []peer.Endpoint{{Network: "tcp", Address: listener.Addr().String()}} + } + if err := manager.AddPeer(config); err != nil { + b.Fatal(err) + } + var server *diam.Server + var served chan error + if side == "Accepted" { + server = &diam.Server{Dict: dictionary, Logger: logger} + if err := manager.BindServer(server); err != nil { + b.Fatal(err) + } + served = make(chan error, 1) + go func() { served <- server.Serve(listener) }() + } + b.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := manager.Close(ctx, sm.DisconnectDoNotWantToTalkToYou); err != nil { + b.Error(err) + } + if server != nil { + if err := server.Close(); err != nil && !errors.Is(err, diam.ErrServerClosed) { + b.Error(err) + } + if err := <-served; err != nil && !errors.Is(err, diam.ErrServerClosed) { + b.Error(err) + } + } + }) + if err := manager.Start(context.Background()); err != nil { + b.Fatal(err) + } + var remote net.Conn + if side == "Accepted" { + remote, err = net.Dial("tcp", listener.Addr().String()) + } else { + if err := listener.(*net.TCPListener).SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + b.Fatal(err) + } + remote, err = listener.Accept() + } + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = remote.Close() }) + if err := remote.SetDeadline(time.Now().Add(10 * time.Minute)); err != nil { + b.Fatal(err) + } + if side == "Accepted" { + cer := diam.NewMessage(diam.CapabilitiesExchange, diam.RequestFlag, 0, 1, 2, dictionary) + addMessageCostCapabilities(cer) + if _, err := cer.WriteTo(remote); err != nil { + b.Fatal(err) + } + cea, err := diam.ReadMessage(remote, dictionary) + if err != nil { + b.Fatal(err) + } + if cea.Header.CommandCode != diam.CapabilitiesExchange || cea.Header.CommandFlags&diam.RequestFlag != 0 { + b.Fatalf("CEA = %+v", cea.Header) + } + messageCostResult(b, cea, diam.Success) + } else { + cer, err := diam.ReadMessage(remote, dictionary) + if err != nil { + b.Fatal(err) + } + if cer.Header.CommandCode != diam.CapabilitiesExchange || cer.Header.CommandFlags&diam.RequestFlag == 0 { + b.Fatalf("CER = %+v", cer.Header) + } + cea := cer.Answer(diam.Success) + addMessageCostCapabilities(cea) + if _, err := cea.WriteTo(remote); err != nil { + b.Fatal(err) + } + } + deadline := time.Now().Add(30 * time.Second) + for { + snapshots := manager.Peers() + if len(snapshots) == 1 && snapshots[0].Eligible { + break + } + if time.Now().After(deadline) { + b.Fatalf("peer never became eligible: %+v", snapshots) + } + time.Sleep(time.Millisecond) + } + return remote +} + +func addMessageCostIdentity(message *diam.Message, host string) { + message.AddAVP(diam.NewAVP(avp.OriginHost, avp.Mbit, 0, datatype.DiameterIdentity(host))) + message.AddAVP(diam.NewAVP(avp.OriginRealm, avp.Mbit, 0, datatype.DiameterIdentity("example"))) +} + +func addMessageCostCapabilities(message *diam.Message) { + addMessageCostIdentity(message, "peer.example") + message.AddAVP(diam.NewAVP(avp.HostIPAddress, avp.Mbit, 0, datatype.AddressFromIP(netip.MustParseAddr("127.0.0.1")))) + message.AddAVP(diam.NewAVP(avp.VendorID, avp.Mbit, 0, datatype.Unsigned32(13))) + message.AddAVP(diam.NewAVP(avp.ProductName, 0, 0, datatype.UTF8String("benchmark"))) + message.AddAVP(diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))) +} diff --git a/diam/peer/message_error_callback_test.go b/diam/peer/message_error_callback_test.go new file mode 100644 index 0000000..58f5168 --- /dev/null +++ b/diam/peer/message_error_callback_test.go @@ -0,0 +1,299 @@ +package peer + +import ( + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/logtest" +) + +// Install logging before Manager and its native TCP connections are created. +// These tests run serially and restore the default after fixture cleanup. +func publicErrorLog(t *testing.T) *logtest.Recorder { + t.Helper() + previous := slog.Default() + records := logtest.New() + slog.SetDefault(records.Logger()) + t.Cleanup(func() { slog.SetDefault(previous) }) + return records +} + +func publicErrorDWR(t *testing.T, p *coreActivityWire) { + t.Helper() + request := watchdogRequest(t) + write(t, p.c, request) + answer := read(t, p.c) + if answer.Header.CommandCode != diam.DeviceWatchdog || answer.Header.CommandFlags&diam.RequestFlag != 0 || answer.Header.HopByHopID != request.Header.HopByHopID || answer.Header.EndToEndID != request.Header.EndToEndID || code(t, answer) != diam.Success { + t.Fatalf("subsequent TCP DWR failed: %+v", answer.Header) + } + p.fence(t) +} + +func publicErrorLogCheck(t *testing.T, records *logtest.Recorder, start int, command uint32, wantType, wantText string) { + t.Helper() + got := records.Records()[start:] + if len(got) != 1 { + t.Fatalf("diagnostic logs=%v; want one", got) + } + record := got[0] + cause := logtest.Attr(record, "error").Any() + if record.Level != slog.LevelWarn || record.Message != "peer: protocol error" || fmt.Sprintf("%T", cause) != wantType || fmt.Sprint(cause) != wantText || logtest.Attr(record, "peer_host").String() != "known.example.net" || logtest.Attr(record, "message.command_code").Uint64() != uint64(command) { + t.Fatalf("diagnostic level=%s message=%q cause=%T(%v) host=%v command=%v", record.Level, record.Message, cause, cause, logtest.Attr(record, "peer_host"), logtest.Attr(record, "message.command_code")) + } +} + +func TestManagerBoundHeaderlessErrorCallbackIgnoredTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, headerless := range []bool{false, true} { + t.Run(fmt.Sprintf("dialed=%t/headerless=%t", dialed, headerless), func(t *testing.T) { + records := publicErrorLog(t) + p := newCoreActivityWire(t, dialed) + start := len(records.Records()) + var message *diam.Message + if headerless { + message = &diam.Message{} + } + if err := p.m.HandleMessageError(p.s.c, message, &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true}); err != nil { + t.Fatal(err) + } + publicErrorDWR(t, p) + if len(records.Records()) != start { + t.Fatal("headerless callback produced a diagnostic") + } + select { + case e := <-p.events: + t.Fatalf("headerless callback reported %+v", e) + default: + } + }) + } + } +} + +// Closing in a public decoder notification describes the supplied fatality. +// The native reader owns transport closure; a direct callback can still send +// without moving the existing watchdog deadline. +func TestManagerFatalBodyCallbackPreservesLiveTransportTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, command := range []uint32{diam.CapabilitiesExchange, diam.DeviceWatchdog, diam.DisconnectPeer} { + for _, request := range []bool{false, true} { + t.Run(fmt.Sprintf("dialed=%t/command=%d/request=%t", dialed, command, request), func(t *testing.T) { + records := publicErrorLog(t) + p := newCoreActivityWire(t, dialed) + start := len(records.Records()) + before := p.state(t) + message := watchdogRequest(t) + switch command { + case diam.CapabilitiesExchange: + message = capabilitiesExchangeRequest(t) + case diam.DisconnectPeer: + message = closingDPR(t, p.m) + } + if !request { + message = message.Answer(diam.Success) + } + description := &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: true, Err: errors.New("callback body failure")} + if err := p.m.HandleMessageError(p.s.c, message, description); err != nil { + t.Fatal(err) + } + event := protocolErrorReceive(t, p.events) + reply := diam.ProtocolReplyNone + var replyCode uint32 + if request { + reply = diam.ProtocolReplyQueued + replyCode = diam.InvalidAVPLength + } + protocolErrorCheck(t, event, p.s.c, *message.Header, diam.ProtocolMalformedMessage, diam.InvalidAVPLength, true, reply, replyCode) + if request { + answer := read(t, p.c) + if answer.Header.CommandCode != command || answer.Header.HopByHopID != message.Header.HopByHopID || answer.Header.EndToEndID != message.Header.EndToEndID || code(t, answer) != diam.InvalidAVPLength { + t.Fatalf("callback error answer=%+v", answer.Header) + } + } + if after := p.state(t); after.timer != before.timer || after.token != before.token || after.health != before.health || after.pending != before.pending || after.num != before.num { + t.Fatalf("fatal callback changed watchdog state/deadline: before=%+v after=%+v", before, after) + } + publicErrorDWR(t, p) + if p.s.c.Closed() || len(records.Records()) != start { + t.Fatal("direct body callback closed or logged the transport") + } + select { + case extra := <-p.events: + t.Fatalf("extra callback notification=%+v", extra) + default: + } + }) + } + } + } +} + +func TestManagerOpenInitiatorCEAPreservesValidationDiagnosticTCP(t *testing.T) { + for _, header := range []bool{false, true} { + t.Run(fmt.Sprintf("header=%t", header), func(t *testing.T) { + records := publicErrorLog(t) + p := newCoreActivityWire(t, true) + message := correlationCEA(t, capabilitiesExchangeRequest(t), "known.example.net") + kind, result, text := diam.ProtocolCEARejected, uint32(diam.MissingAVP), "diameter message error 5005: Diameter validation failed (5005): required AVP missing" + if header { + message.Header.CommandFlags |= diam.ProxiableFlag + kind, result, text = diam.ProtocolInvalidHeader, diam.InvalidHDRBits, "diameter message error 3008: invalid base command flags (RFC 6733 §§3, 3.2)" + } else { + message.AVP = nil + } + start := len(records.Records()) + write(t, p.c, message) + event := protocolErrorReceive(t, p.events) + expected := *message.Header + expected.MessageLength = uint32(message.Len()) + protocolErrorCheck(t, event, p.s.c, expected, kind, result, false, diam.ProtocolReplyNone, 0, 0) + publicErrorDWR(t, p) + publicErrorLogCheck(t, records, start, diam.CapabilitiesExchange, "*diam.MessageError", text) + }) + } +} + +func TestManagerClosingDPRPreservesDecoderDiagnosticTCP(t *testing.T) { + for _, dialed := range []bool{false, true} { + for _, fault := range []string{"grammar", "callback-header", "fatal-callback-header"} { + t.Run(fmt.Sprintf("dialed=%t/%s", dialed, fault), func(t *testing.T) { + records := publicErrorLog(t) + p := newCoreActivityWire(t, dialed) + p.a.post(event{kind: stop}) + local := read(t, p.c) + if local.Header.CommandCode != diam.DisconnectPeer { + t.Fatalf("local shutdown=%+v", local.Header) + } + p.fence(t) + start := len(records.Records()) + message := closingDPR(t, p.m) + kind, result, text := diam.ProtocolInvalidRequest, uint32(diam.MissingAVP), "diameter message error 5005: Diameter validation failed (5005): required AVP missing" + fatal := fault == "fatal-callback-header" + if fault == "grammar" { + message.AVP = nil + write(t, p.c, message) + } else { + message.Header.CommandFlags |= diam.ProxiableFlag + kind, result, text = diam.ProtocolMalformedMessage, diam.InvalidHDRBits, "diameter message error 3008: invalid base command flags (RFC 6733 §§3, 3.2)" + if err := p.m.HandleMessageError(p.s.c, message, &diam.MessageError{ResultCode: diam.InvalidAVPLength, Fatal: fatal}); err != nil { + t.Fatal(err) + } + } + event := protocolErrorReceive(t, p.events) + expected := *message.Header + if fault == "grammar" { + expected.MessageLength = uint32(message.Len()) + } + stream := ^uint(0) + if fault == "grammar" { + stream = 0 + } + protocolErrorCheck(t, event, p.s.c, expected, kind, result, fatal, diam.ProtocolReplyNone, 0, stream) + p.fence(t) + publicErrorLogCheck(t, records, start, diam.DisconnectPeer, "*diam.MessageError", text) + // A later valid crossed DPR still gets its normal DPA in Closing. + crossed := closingDPR(t, p.m) + write(t, p.c, crossed) + answer := read(t, p.c) + if answer.Header.CommandCode != diam.DisconnectPeer || answer.Header.HopByHopID != crossed.Header.HopByHopID || answer.Header.EndToEndID != crossed.Header.EndToEndID || code(t, answer) != diam.Success { + t.Fatalf("crossed DPR after malformed callback=%+v", answer.Header) + } + }) + } + } +} + +func TestManagerControlAnswerErrorIdentityTCP(t *testing.T) { + for _, scenario := range []string{"unmatched-cea", "unowned-dwa", "unmatched-dpa", "dpa-identifiers", "dpa-host", "dpa-result"} { + t.Run(scenario, func(t *testing.T) { + records := publicErrorLog(t) + p := newCoreActivityWire(t, false) + var message *diam.Message + var err error + kind, result, closing := diam.ProtocolUnexpectedMessage, uint32(0), false + text := "peer: unmatched answer (RFC 6733 §6.2.1)" + switch scenario { + case "unmatched-cea": + message = correlationCEA(t, capabilitiesExchangeRequest(t), "known.example.net") + case "unowned-dwa": + message, err = base.BuildDWA(watchdogRequest(t), testBase("known.example.net")) + kind, text = diam.ProtocolInvalidDWA, "peer: unsolicited or mismatched DWA" + case "unmatched-dpa": + message, err = base.BuildDPA(closingDPR(t, p.m), testBase("known.example.net")) + default: + p.a.post(event{kind: stop}) + local := read(t, p.c) + p.fence(t) + message, err = base.BuildDPA(local, testBase("known.example.net")) + kind = diam.ProtocolInvalidDPA + switch scenario { + case "dpa-identifiers": + message.Header.HopByHopID++ + text = "peer: DPA identifiers mismatch" + case "dpa-host": + field, e := message.FindAVP(avp.OriginHost, 0) + if e != nil { + t.Fatal(e) + } + field.Data = datatype.DiameterIdentity("other.example.net") + text = "peer: DPA Origin-Host mismatch" + case "dpa-result": + field, e := message.FindAVP(avp.ResultCode, 0) + if e != nil { + t.Fatal(e) + } + field.Data = datatype.Unsigned32(diam.UnableToComply) + kind, result, closing, text = diam.ProtocolPeerRejected, diam.UnableToComply, true, "peer: DPA Result-Code 5012" + } + } + if err != nil { + t.Fatal(err) + } + start := len(records.Records()) + write(t, p.c, message) + event := protocolErrorReceive(t, p.events) + expected := *message.Header + expected.MessageLength = uint32(message.Len()) + protocolErrorCheck(t, event, p.s.c, expected, kind, result, closing, diam.ProtocolReplyNone, 0, 0) + p.fence(t) + publicErrorLogCheck(t, records, start, message.Header.CommandCode, "*errors.errorString", text) + if closing { + correlationEOF(t, p.c) + } else if scenario == "dpa-identifiers" || scenario == "dpa-host" { + p.clock.fire() + correlationEOF(t, p.c) + } else { + publicErrorDWR(t, p) + } + }) + } +} + +func TestManagerPendingCEAErrorIdentityTCP(t *testing.T) { + records := publicErrorLog(t) + m, c, cer, events := correlationDial(t, &fakeClock{}) + m.mu.RLock() + a := m.peers["known.example.net"] + m.mu.RUnlock() + var s *session + a.commit(event{testHook: func() { s = a.i }}, newProtocolDelivery()) + message := correlationCEA(t, cer, "known.example.net") + message.Header.HopByHopID++ + start := len(records.Records()) + write(t, c, message) + notice := protocolErrorReceive(t, events) + expected := *message.Header + expected.MessageLength = uint32(message.Len()) + protocolErrorCheck(t, notice, s.c, expected, diam.ProtocolUnexpectedMessage, 0, false, diam.ProtocolReplyNone, 0, 0) + a.commit(event{}, newProtocolDelivery()) + publicErrorLogCheck(t, records, start, diam.CapabilitiesExchange, "*errors.errorString", "peer: CEA identifiers mismatch") + write(t, c, correlationCEA(t, cer, "known.example.net")) + awaitState(t, m, IOpen) + publicErrorDWR(t, &coreActivityWire{m: m, c: c, a: a, s: s}) +} diff --git a/diam/peer/peer_table_core_fixture_test.go b/diam/peer/peer_table_core_fixture_test.go new file mode 100644 index 0000000..4213581 --- /dev/null +++ b/diam/peer/peer_table_core_fixture_test.go @@ -0,0 +1,17 @@ +package peer + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +// A newly accepted transport has a started responder core but no CER candidate. +// Candidate parsing and election remain production Receive effects. +func peerTableResponder(t *testing.T, s *session) { + t.Helper() + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) +} diff --git a/diam/peer/peer_test.go b/diam/peer/peer_test.go index 1b15112..fa5e281 100644 --- a/diam/peer/peer_test.go +++ b/diam/peer/peer_test.go @@ -13,6 +13,7 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/internal/testutil" "github.com/gomaja/go-diameter/diam/sm" ) @@ -62,7 +63,7 @@ func assertActorRow(t *testing.T, state PeerState, kind psmEvent, row psmStep) { t.Helper() clock := &fakeClock{} - m, err := New(Config{Settings: sm.Settings{OriginHost: "local.example.net", OriginRealm: "example.net", VendorID: 1, ProductName: "table-test"}, Clock: clock}) + m, err := New(Config{Settings: sm.Settings{Dict: testDict, OriginHost: "local.example.net", OriginRealm: "example.net", VendorID: 1, ProductName: "table-test"}, Clock: clock}) if err != nil { t.Fatal(err) } @@ -75,11 +76,23 @@ func assertActorRow(t *testing.T, state PeerState, kind psmEvent, row psmStep) { defer i.close() defer r.close() defer candidate.close() + r.inbound, candidate.inbound = true, true + renewalCoreSession(t, i, state != Closed && state != WaitConnAck && state != WaitConnAckElect, + state == IOpen || state == Closing, "example.net") + firstCER := kind == rConnCER && (state == Closed || state == WaitConnAck || state == WaitICEA) + if firstCER { + peerTableResponder(t, r) + } else { + renewalCoreSession(t, r, true, state == ROpen || state == Closing, "example.net") + } + peerTableResponder(t, candidate) a.i = i a.r = r - if state == IOpen { + a.state, a.nextGen = state, 1 + a.active = nil + if state == IOpen || state == Closing && strings.HasPrefix(string(kind), "I-") { a.active = i - } else { + } else if state == ROpen || state == Closing { a.active = r } a.publish(nil) @@ -87,7 +100,6 @@ func assertActorRow(t *testing.T, state PeerState, kind psmEvent, row psmStep) { if err != nil { t.Fatal(err) } - r.cerRequest = cer var msg *diam.Message s := r switch kind { @@ -107,10 +119,72 @@ func assertActorRow(t *testing.T, state PeerState, kind psmEvent, row psmStep) { if kind == rConnCER && state != Closed && state != WaitConnAck && state != WaitICEA { s = candidate } - a.step(kind, s, msg) + if kind == stop { + s = a.active + } + var dpr *diam.Message + if state == Closing { + a.active.applyProtocol(protocol.Input{Kind: protocol.Disconnect, Cause: uint32(a.closeCause)}, nil) + w := renewalCoreWrite(t, a.active, diam.DisconnectPeer) + dpr = w.msg + fixtureCompleteWrite(a.active, w) + } + switch kind { + case rConnCER: + // A fresh transport can produce a candidate before it owns an actor + // slot. Its core's ValidatedCER effect performs the normal election. + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: cer}, nil) + case iCEA: + msg, err = base.BuildCEA(i.cerRequest, testBase("remote.example.net"), diam.Success) + if err == nil { + a.onWire(event{s: i, msg: msg}) + } + case iDWR, rDWR, iDPR, rDPR, iMessage, rMessage: + a.onWire(event{s: s, msg: msg}) + case iDWA, rDWA: + fixtureExpireWatchdog(a) + w := renewalCoreWrite(t, s, diam.DeviceWatchdog) + fixtureCompleteWrite(s, w) + msg, err = base.BuildDWA(w.msg, testBase("remote.example.net")) + if err == nil { + a.onWire(event{s: s, msg: msg}) + if s.protocolSnapshot().Watchdog().Pending { + t.Fatal("correlated DWA did not complete the probe") + } + } + case iDPA, rDPA: + msg, err = base.BuildDPA(dpr, testBase("remote.example.net")) + if err == nil { + a.onWire(event{s: s, msg: msg}) + } + case iNonCEA: + msg, err = base.BuildDWR(testDict, testBase("remote.example.net"), 0) + if err == nil { + a.onWire(event{s: i, msg: msg}) + } + case stop: + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect, Cause: uint32(a.closeCause)}, nil) + case iDisc, rDisc: + s.close() + a.onGone(s) + case timeout: + if state == Closing { + reportingExpire(a.active, protocol.ClosingTimer) + } else { + a.step(kind, s, msg) + } + default: + a.step(kind, s, msg) + } + if err != nil { + t.Fatal(err) + } if a.state != row.next { t.Fatalf("actor state=%s want %s", a.state, row.next) } + if row.next == IOpen && a.active != i || row.next == ROpen && a.active != r { + t.Fatal("election did not preserve the selected connection") + } checkWrite := func(ss *session, command uint32, request bool) { t.Helper() select { @@ -118,6 +192,7 @@ func assertActorRow(t *testing.T, state PeerState, kind psmEvent, row psmStep) { if w.msg.Header.CommandCode != command || (w.msg.Header.CommandFlags&diam.RequestFlag != 0) != request { t.Fatalf("write command=%d flags=%x", w.msg.Header.CommandCode, w.msg.Header.CommandFlags) } + fixtureCompleteWrite(ss, w) default: t.Fatalf("missing write command %d", command) } @@ -277,8 +352,10 @@ func TestStaleCandidateEventsIgnored(t *testing.T) { t.Fatal(err) } a := &actor{m: m, cfg: PeerConfig{Host: "peer.example.net"}, state: ROpen, done: make(chan struct{}), token: 7} - winner := &session{gen: 3} - loser := &session{gen: 2} + winner := &session{gen: 3, c: newFakeConn(testDict)} + loser := &session{gen: 2, c: newFakeConn(testDict)} + defer winner.c.Close() + defer loser.c.Close() a.r = winner a.active = winner a.publish(nil) diff --git a/diam/peer/protocol_effect_storage_test.go b/diam/peer/protocol_effect_storage_test.go new file mode 100644 index 0000000..ad74625 --- /dev/null +++ b/diam/peer/protocol_effect_storage_test.go @@ -0,0 +1,84 @@ +package peer + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +func TestCompletedProtocolEffectsReleasePayloads(t *testing.T) { + s := &session{} + storage := make([]protocol.Effect, 8) + for i := range storage { + storage[i] = protocol.Effect{Kind: protocol.Hold, Token: uint64(i + 1), Input: &protocol.Input{Token: uint64(i + 1)}} + } + s.releaseProtocolEffects(storage[:1]) + if cap(s.coreEffects) != cap(storage) || len(s.coreEffects) != 0 { + t.Fatal("completed batch did not return its storage") + } + for _, effect := range storage { + if effect != (protocol.Effect{}) { + t.Fatal("completed batch retained a compacted payload") + } + } + // A smaller nested batch cannot displace capacity already returned by another + // completed decision. Every returned batch releases its own payloads. + smaller := []protocol.Effect{{Kind: protocol.Hold, Input: &protocol.Input{Token: 99}}} + s.releaseProtocolEffects(smaller) + if cap(s.coreEffects) != cap(storage) || smaller[0] != (protocol.Effect{}) { + t.Fatal("nested completion lost capacity or retained its payload") + } +} + +func TestProtocolBatchStorageIsLentUntilExecutionCompletes(t *testing.T) { + s := &session{coreEffects: make([]protocol.Effect, 0, 8)} + // A mismatched connection appends no effects but still owns its borrowed + // storage until execution ends, so a nested decision cannot overwrite it. + cfg := protocol.Config{Connection: protocol.Connection{Instance: 1}, Role: protocol.Responder, Local: testBase("local.example.net")} + core, err := protocol.New(cfg) + if err != nil { + t.Fatal(err) + } + s.core = core + s.coreMu.Lock() + outer := s.handleProtocolLocked(protocol.Input{}) + inner := s.handleProtocolLocked(protocol.Input{}) + s.coreMu.Unlock() + if cap(outer) != 8 || cap(inner) != 0 { + t.Fatal("nested decision borrowed an executing batch") + } + s.releaseProtocolEffects(inner) + s.releaseProtocolEffects(outer) + if cap(s.coreEffects) != 8 { + t.Fatal("outer batch storage was not returned") + } +} + +func TestNestedProtocolBatchesReuseCompletedStorage(t *testing.T) { + core, err := protocol.New(protocol.Config{Connection: protocol.Connection{Instance: 1}, Role: protocol.Responder, Local: testBase("local.example.net")}) + if err != nil { + t.Fatal(err) + } + s := &session{core: core} + s.releaseProtocolEffects(make([]protocol.Effect, 0, 8)) + s.releaseProtocolEffects(make([]protocol.Effect, 0, 2)) + allocations := testing.AllocsPerRun(100, func() { + s.coreMu.Lock() + outer := s.handleProtocolLocked(protocol.Input{}) + inner := s.handleProtocolLocked(protocol.Input{}) + s.coreMu.Unlock() + if cap(outer) < 8 || cap(inner) < 2 { + t.Fatal("nested execution lost completed batch storage") + } + outer = append(outer, protocol.Effect{Token: 7}) + inner = append(inner, protocol.Effect{Token: 8}) + if outer[0].Token != 7 { + t.Fatal("nested execution overwrote outer effects") + } + s.releaseProtocolEffects(inner) + s.releaseProtocolEffects(outer) + }) + if allocations != 0 { + t.Fatalf("nested protocol execution allocated %.0f buffers", allocations) + } +} diff --git a/diam/peer/protocol_error.go b/diam/peer/protocol_error.go index 4c044f2..197c37e 100644 --- a/diam/peer/protocol_error.go +++ b/diam/peer/protocol_error.go @@ -1,11 +1,8 @@ package peer import ( - "errors" - "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/internal/observe" - "github.com/gomaja/go-diameter/diam/sm/smparser" ) // ProtocolErrorEnabled reports whether Settings.OnProtocolError is configured. @@ -50,20 +47,4 @@ func (a *actor) protocolError(c diam.Conn, msg *diam.Message, kind diam.Protocol a.m.protocolError(c, msg, kind, result, closing, reply, replyCode, a.delivery) } -func protocolResultCode(err error) uint32 { - var messageError *diam.MessageError - if errors.As(err, &messageError) { - return messageError.ResultCode - } - var failed *smparser.ErrFailedResultCode - if errors.As(err, &failed) { - return failed.ResultCode - } - var dwaError *dwaResultCodeError - if errors.As(err, &dwaError) { - return dwaError.result - } - return 0 -} - var _ diam.ProtocolErrorObserver = (*Manager)(nil) diff --git a/diam/peer/protocol_error_test.go b/diam/peer/protocol_error_test.go index 08ce189..30a0f92 100644 --- a/diam/peer/protocol_error_test.go +++ b/diam/peer/protocol_error_test.go @@ -16,6 +16,7 @@ import ( "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" "github.com/gomaja/go-diameter/diam/internal/logtest" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm" "github.com/gomaja/go-diameter/diam/sm/smparser" "github.com/gomaja/go-diameter/diam/sm/smpeer" @@ -69,28 +70,36 @@ func TestProtocolErrorManagerWireDecisions(t *testing.T) { }, diam.ProtocolInvalidDWR, 0, 0, false, false}, {"invalid DPR", func(t *testing.T) *diam.Message { return diam.NewRequest(diam.DisconnectPeer, 0, testDict) }, diam.ProtocolInvalidRequest, diam.MissingAVP, diam.MissingAVP, false, false}, {"unsolicited DWA", func(t *testing.T) *diam.Message { - m, err := base.BuildDWA(reviewDWR(t), testBase("known.example.net")) + m, err := base.BuildDWA(watchdogRequest(t), testBase("known.example.net")) if err != nil { t.Fatal(err) } return m }, diam.ProtocolInvalidDWA, 0, 0, false, false}, - {"invalid base header", func(t *testing.T) *diam.Message { m := reviewDWR(t); m.Header.ApplicationID = 4; return m }, diam.ProtocolInvalidHeader, 3008, 3008, false, false}, - {"invalid answer header", func(t *testing.T) *diam.Message { m := reviewDWR(t).Answer(2001); m.Header.ApplicationID = 4; return m }, diam.ProtocolInvalidHeader, 3008, 0, false, false}, - {"invalid initial CER header", func(t *testing.T) *diam.Message { m := reviewCER(t); m.Header.ApplicationID = 4; return m }, diam.ProtocolInvalidHeader, 3008, 3008, true, true}, + {"invalid base header", func(t *testing.T) *diam.Message { m := watchdogRequest(t); m.Header.ApplicationID = 4; return m }, diam.ProtocolInvalidHeader, 3008, 3008, false, false}, + {"invalid answer header", func(t *testing.T) *diam.Message { + m := watchdogRequest(t).Answer(2001) + m.Header.ApplicationID = 4 + return m + }, diam.ProtocolInvalidHeader, 3008, 0, false, false}, + {"invalid initial CER header", func(t *testing.T) *diam.Message { + m := capabilitiesExchangeRequest(t) + m.Header.ApplicationID = 4 + return m + }, diam.ProtocolInvalidHeader, 3008, 3008, true, true}, {"unknown CER identity", func(t *testing.T) *diam.Message { - m := reviewCER(t) + m := capabilitiesExchangeRequest(t) a, _ := m.FindAVP(avp.OriginHost, 0) a.Data = datatype.DiameterIdentity("unknown.example.net") return m }, diam.ProtocolCERRejected, 3010, 3010, true, true}, {"malformed AVP payload", func(t *testing.T) *diam.Message { - m := reviewDWR(t) + m := watchdogRequest(t) m.AddAVP(diam.NewAVP(avp.InbandSecurityID, avp.Mbit, 0, datatype.Unknown{1, 2})) return m }, diam.ProtocolMalformedMessage, 5014, 5014, false, false}, - {"duplicate CER", reviewCER, diam.ProtocolUnexpectedMessage, 0, 0, false, false}, - {"pre CER traffic", reviewDWR, diam.ProtocolHandshakeState, 0, 0, true, true}, + {"duplicate CER", capabilitiesExchangeRequest, diam.ProtocolUnexpectedMessage, 0, 0, false, false}, + {"pre CER traffic", watchdogRequest, diam.ProtocolHandshakeState, 0, 0, true, true}, } { t.Run(tc.name, func(t *testing.T) { events := make(chan diam.ProtocolErrorEvent, 8) @@ -113,7 +122,7 @@ func TestProtocolErrorManagerWireDecisions(t *testing.T) { } t.Cleanup(func() { _ = c.Close() }) if !tc.preCER { - write(t, c, reviewCER(t)) + write(t, c, capabilitiesExchangeRequest(t)) if code(t, read(t, c)) != 2001 { t.Fatal("handshake failed") } @@ -163,7 +172,7 @@ func TestProtocolErrorManagerWireDecisions(t *testing.T) { t.Fatal("closing decision left server connection open") } } else { - write(t, c, reviewDWR(t)) + write(t, c, watchdogRequest(t)) if code(t, read(t, c)) != 2001 { t.Fatal("connection not usable") } @@ -193,6 +202,7 @@ func TestProtocolErrorManagerMessageFailures(t *testing.T) { t.Run(map[bool]string{false: "queued", true: "queue full"}[full], func(t *testing.T) { a, s, _ := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + reportingOpen(t, s) events := make(chan diam.ProtocolErrorEvent, 8) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } if full { @@ -200,7 +210,7 @@ func TestProtocolErrorManagerMessageFailures(t *testing.T) { s.writes <- writeRequest{} } } - msg := reviewDWR(t) + msg := watchdogRequest(t) msg.Header.ApplicationID = 4 original := *msg.Header a.onWire(event{s: s, msg: msg, messageErr: &diam.MessageError{ResultCode: 5014, Fatal: true}}) @@ -231,11 +241,12 @@ func TestProtocolErrorManagerMessageFailures(t *testing.T) { func TestProtocolErrorManagerQueuedThenClosed(t *testing.T) { testDict := newTestDictionary(creditcontrol.Dictionary()) - a, s, _ := failureReportingActor(t) + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) var got diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { got = e; e.Conn.Close() } msg := diam.NewRequest(999, 0, testDict) - a.m.unsupported(s, msg) + reportingReceive(a, s, msg, nil) protocolErrorCheck(t, got, s.c, *msg.Header, diam.ProtocolUnsupportedCommand, 3001, false, diam.ProtocolReplyQueued, 3001) if !s.c.Closed() || len(s.writes) != 1 { t.Fatal("callback did not follow queue admission") @@ -246,9 +257,10 @@ func TestProtocolErrorManagerMalformedAndRejectedAnswers(t *testing.T) { for _, request := range []bool{false, true} { a, s, _ := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + reportingOpen(t, s) var events []diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events = append(events, e) } - msg := reviewDWR(t) + msg := watchdogRequest(t) if !request { msg.Header.CommandFlags = 0 } @@ -269,20 +281,19 @@ func TestProtocolErrorManagerPeerEventOverflow(t *testing.T) { a, s, _ := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + reportingOpen(t, s) for len(a.m.callbackQ) < cap(a.m.callbackQ) { a.m.callbackQ <- PeerEvent{} } var calls int a.m.cfg.Settings.OnProtocolError = func(diam.ProtocolErrorEvent) { calls++ } - a.m.unsupported(s, diam.NewRequest(999, 0, testDict)) + a.onWire(event{s: s, msg: diam.NewRequest(999, 0, testDict)}) if calls != 1 { t.Fatalf("protocol callbacks=%d", calls) } } func TestProtocolErrorManagerDWAAndDPA(t *testing.T) { - testDict := newTestDictionary(creditcontrol.Dictionary()) - for _, tc := range []struct { name string command uint32 @@ -318,22 +329,16 @@ func TestProtocolErrorManagerDWAAndDPA(t *testing.T) { t.Run(tc.name, func(t *testing.T) { a, s, _ := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + reportingOpen(t, s) var msg *diam.Message var err error if tc.command == diam.DeviceWatchdog { - msg, err = base.BuildDWA(reviewDWR(t), testBase("known.example.net")) - a.pendingWatchdog = true - a.watchdogGen = s.gen - a.watchdogHop, a.watchdogEnd = msg.Header.HopByHopID, msg.Header.EndToEndID + msg = testDWA(t, a) } else { - req, buildErr := base.BuildDPR(testDict, testBase("known.example.net"), 0) - if buildErr != nil { - t.Fatal(buildErr) - } - msg, err = base.BuildDPA(req, testBase("known.example.net")) - a.state = Closing - a.pendingDPR, a.pendingDPREnd = msg.Header.HopByHopID, msg.Header.EndToEndID - a.pendingDPRGen, a.pendingDPRCommand = s.gen, diam.DisconnectPeer + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + w := requiredQueuedWrite(t, s) + fixtureCompleteWrite(s, w) + msg, err = base.BuildDPA(w.msg, testBase("known.example.net")) } if err != nil { t.Fatal(err) @@ -371,34 +376,33 @@ func TestProtocolErrorManagerHandshakeDecisions(t *testing.T) { run func(*testing.T, *actor, *session) *diam.Message }{ {"non CEA", diam.ProtocolHandshakeState, 0, func(t *testing.T, a *actor, s *session) *diam.Message { - a.state, a.i = WaitICEA, s - msg := reviewDWR(t) + reportingInitiator(t, a, s, true) + msg := watchdogRequest(t) a.onWire(event{s: s, msg: msg}) return msg }}, {"CEA identifiers", diam.ProtocolUnexpectedMessage, 0, func(t *testing.T, a *actor, s *session) *diam.Message { - a.state, a.i = WaitICEA, s - msg, err := base.BuildCEA(reviewCER(t), testBase("known.example.net"), 2001) + request := reportingInitiator(t, a, s, true) + msg, err := base.BuildCEA(request, testBase("known.example.net"), 2001) if err != nil { t.Fatal(err) } - s.cerHop = msg.Header.HopByHopID + 1 + msg.Header.HopByHopID++ a.onWire(event{s: s, msg: msg}) return msg }}, {"CEA rejected", diam.ProtocolCEARejected, 3010, func(t *testing.T, a *actor, s *session) *diam.Message { - a.state, a.i = WaitICEA, s - msg, err := base.BuildCEA(reviewCER(t), testBase("known.example.net"), 3010) + request := reportingInitiator(t, a, s, true) + msg, err := base.BuildCEA(request, testBase("known.example.net"), 3010) if err != nil { t.Fatal(err) } - s.cerHop, s.cerEnd = msg.Header.HopByHopID, msg.Header.EndToEndID a.onWire(event{s: s, msg: msg}) return msg }}, {"CER closing", diam.ProtocolHandshakeState, 0, func(t *testing.T, a *actor, s *session) *diam.Message { a.state = Closing - msg := reviewCER(t) + msg := capabilitiesExchangeRequest(t) a.onCER(event{s: s, msg: msg, meta: smpeer.FromCER(&smparser.CER{OriginHost: "known.example.net"})}) return msg }}, @@ -429,20 +433,32 @@ func TestProtocolErrorManagerTimersAndRetirement(t *testing.T) { kind diam.ProtocolErrorKind fire func(*actor) }{ - {"handshake", WaitICEA, WatchdogInitial, diam.ProtocolHandshakeTimeout, func(a *actor) { a.handle(event{kind: timeout}) }}, - {"disconnect", Closing, WatchdogOkay, diam.ProtocolDisconnectTimeout, func(a *actor) { a.handle(event{kind: timeout}) }}, + {"handshake", WaitICEA, WatchdogInitial, diam.ProtocolHandshakeTimeout, func(a *actor) { reportingExpire(a.active, protocol.HandshakeTimer) }}, + {"disconnect", Closing, WatchdogOkay, diam.ProtocolDisconnectTimeout, func(a *actor) { reportingExpire(a.active, protocol.ClosingTimer) }}, {"watchdog", IOpen, WatchdogSuspect, diam.ProtocolWatchdogTimeout, func(a *actor) { a.watchdogTick() }}, } { t.Run(tc.name, func(t *testing.T) { a, s, _ := failureReportingActor(t) a.state, a.i, a.active, a.watchdog = tc.state, s, s, tc.watchdog + switch tc.name { + case "handshake": + reportingInitiator(t, a, s, true) + a.active = s + case "disconnect": + a.state = IOpen + reportingOpen(t, s) + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + fixtureCompleteWrite(s, requiredQueuedWrite(t, s)) + case "watchdog": + reportingOpen(t, s) + } var events []diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events = append(events, e) } tc.fire(a) if len(events) != 1 || events[0].Conn != s.c || events[0].Message.HasHeader || !events[0].Closing || !errors.Is(events[0].Err, tc.kind) || events[0].Reply != diam.ProtocolReplyNone { t.Fatalf("events=%+v", events) } - a.handle(event{kind: timeout, token: a.token - 1}) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.HandshakeTimer, Token: 1}, nil) if len(events) != 1 { t.Fatal("retired timer notified") } @@ -450,6 +466,7 @@ func TestProtocolErrorManagerTimersAndRetirement(t *testing.T) { } t.Run("retired answer keeps original connection", func(t *testing.T) { a, s, _ := failureReportingActor(t) + reportingOpen(t, s) var got diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { got = e } replacement := newFakeConn(testDict) @@ -471,13 +488,14 @@ func (c protocolErrorLoggedConn) Logger() *slog.Logger { return c.logger } func TestProtocolErrorManagerCallbackPanic(t *testing.T) { testDict := newTestDictionary(creditcontrol.Dictionary()) - a, s, _ := failureReportingActor(t) + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) records := logtest.New() s.c = protocolErrorLoggedConn{Conn: s.c, logger: records.Logger()} var calls int a.m.cfg.Settings.OnProtocolError = func(diam.ProtocolErrorEvent) { calls++; panic("callback failure") } for range 2 { - a.m.unsupported(s, diam.NewRequest(999, 0, testDict)) + reportingReceive(a, s, diam.NewRequest(999, 0, testDict), nil) } if calls != 2 || len(s.writes) != 2 || s.c.Closed() { t.Fatal("panic changed handling or delivery") @@ -496,10 +514,11 @@ func TestProtocolErrorManagerCallbackPanic(t *testing.T) { func TestProtocolErrorManagerCallbackBackpressure(t *testing.T) { testDict := newTestDictionary(creditcontrol.Dictionary()) - a, s, _ := failureReportingActor(t) + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { close(entered); <-release } - go func() { a.m.unsupported(s, diam.NewRequest(999, 0, testDict)); close(done) }() + go func() { reportingReceive(a, s, diam.NewRequest(999, 0, testDict), nil); close(done) }() select { case <-entered: case <-time.After(time.Second): @@ -524,7 +543,8 @@ func TestProtocolErrorManagerCallbackBackpressure(t *testing.T) { func TestProtocolErrorManagerConcurrentCallbacks(t *testing.T) { testDict := newTestDictionary(creditcontrol.Dictionary()) - a, s, _ := failureReportingActor(t) + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) entered, release, done := make(chan struct{}, 2), make(chan struct{}), make(chan struct{}, 2) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { _ = a.m.getSession(s.c) @@ -533,7 +553,7 @@ func TestProtocolErrorManagerConcurrentCallbacks(t *testing.T) { <-release } for range 2 { - go func() { a.m.unsupported(s, diam.NewRequest(999, 0, testDict)); done <- struct{}{} }() + go func() { reportingReceive(a, s, diam.NewRequest(999, 0, testDict), nil); done <- struct{}{} }() } for range 2 { select { @@ -562,6 +582,15 @@ func TestProtocolErrorManagerLocalReplyFailures(t *testing.T) { t.Run(tc.name, func(t *testing.T) { a, s, records := failureReportingActor(t) a.state, a.i, a.active = IOpen, s, s + if tc.name == "CEA build" { + a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} + s.cerRequest = reportingCandidate(t, a, s) + } else { + reportingOpen(t, s) + for len(a.m.callbackQ) > 0 { + <-a.m.callbackQ + } + } var events []diam.ProtocolErrorEvent a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events = append(events, e) } var cause error @@ -579,9 +608,8 @@ func TestProtocolErrorManagerLocalReplyFailures(t *testing.T) { } switch tc.name { case "DWA enqueue": - a.step(iDWR, s, reviewDWR(t)) + a.onWire(event{s: s, msg: watchdogRequest(t)}) case "CEA build": - s.cerRequest = reviewCER(t) if a.sendCEA(s) { t.Fatal("invalid CEA settings unexpectedly built an answer") } @@ -597,7 +625,11 @@ func TestProtocolErrorManagerLocalReplyFailures(t *testing.T) { t.Fatalf("failed control message advanced state: writes=%d want=%d watchdog=%v DPR=%d", len(s.writes), wantWrites, a.pendingWatchdog, a.pendingDPR) } assertFailureRecord(t, records, slog.LevelError, "peer: "+tc.operation+" failed; closing connection", s.c.RemoteAddr().String(), cause) - assertFailureEvents(t, a, []PeerState{Closed, Closed}, []WatchdogState{WatchdogInitial, WatchdogInitial}, []string{"", "peer: " + tc.operation + ":"}) + if tc.name == "CEA build" { + assertFailureEvents(t, a, []PeerState{Closed, Closed}, []WatchdogState{WatchdogInitial, WatchdogInitial}, []string{"", "peer: " + tc.operation + ":"}) + } else { + assertFailureEvents(t, a, []PeerState{Closed, Closed}, []WatchdogState{WatchdogOkay, WatchdogDown}, []string{"peer: " + tc.operation + ":", "peer: " + tc.operation + ":"}) + } if tc.name == "DWA enqueue" || tc.name == "CEA build" { if len(events) != 1 || !errors.Is(events[0].Err, diam.ProtocolReplyFailure) || !events[0].Closing || events[0].Reply != diam.ProtocolReplyFailed || events[0].ReplyCode != diam.Success { t.Fatalf("required reply failure notification: %+v", events) @@ -632,7 +664,7 @@ func TestProtocolErrorManagerWirePendingAnswers(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = c.Close() }) - write(t, c, reviewCER(t)) + write(t, c, capabilitiesExchangeRequest(t)) read(t, c) clock.waitTimers(t, 2) m.mu.RLock() @@ -743,7 +775,7 @@ func TestProtocolErrorManagerWirePendingAnswers(t *testing.T) { t.Fatal("valid DPA did not complete close") } } else { - write(t, c, reviewDWR(t)) + write(t, c, watchdogRequest(t)) if code(t, read(t, c)) != 2001 { t.Fatal("invalid DWA changed connection") } diff --git a/diam/peer/protocol_test.go b/diam/peer/protocol_test.go index ae6013b..26d810e 100644 --- a/diam/peer/protocol_test.go +++ b/diam/peer/protocol_test.go @@ -570,6 +570,9 @@ func TestFakeTransportOpenControl(t *testing.T) { a.r = s a.active = s a.publish(nil) + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } cfg := testBase("known.example.net") dwr, err := base.BuildDWR(testDict, cfg, 0) if err != nil { @@ -623,6 +626,13 @@ func TestMismatchedDPRIsReportedWithoutDPA(t *testing.T) { } a.active = s a.publish(nil) + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } + if state == Closing { + a.stop(sm.DisconnectRebooting) + <-s.writes + } bad, err := base.BuildDPR(testDict, testBase("other.example.net"), 0) if err != nil { t.Fatal(err) @@ -679,6 +689,9 @@ func TestFakeTransportDPAIdentifiersAndPeer(t *testing.T) { } a.active = s a.publish(nil) + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } a.stop(sm.DisconnectBusy) if a.state != Closing { t.Fatalf("state=%s", a.state) diff --git a/diam/peer/queued_reply_delivery_test.go b/diam/peer/queued_reply_delivery_test.go new file mode 100644 index 0000000..c8f80bf --- /dev/null +++ b/diam/peer/queued_reply_delivery_test.go @@ -0,0 +1,218 @@ +package peer + +import ( + "errors" + "io" + "sync" + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" + "github.com/gomaja/go-diameter/diam/sm" +) + +type queuedReplyWriteConn struct { + *fakeConn + err error + short bool + entered chan struct{} + release <-chan struct{} +} + +func (c *queuedReplyWriteConn) WriteStream(b []byte, _ uint) (int, error) { + if c.entered != nil { + close(c.entered) + <-c.release + } + if c.short { + return len(b) - 1, nil + } + if c.err != nil { + return 0, c.err + } + return len(b), nil +} + +func queuedReplyGate(t *testing.T) (chan struct{}, func()) { + t.Helper() + ch := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(ch) }) } + t.Cleanup(release) + return ch, release +} + +// Post directly to the running actor so production owns both completion and +// reporting. Taking the selected writer only installs deterministic barriers; +// its original delivery and protocol token remain intact. +func actorSelectedReply(t *testing.T, a *actor, s *session, request *diam.Message) writeRequest { + t.Helper() + a.post(event{kind: wireEvent, s: s, msg: request}) + actorDeliveryFence(t, a) + w := requiredQueuedWrite(t, s) + if w.replyDelivery == nil { + t.Fatal("actor-selected writer has no completion delivery") + } + t.Cleanup(w.replyDelivery.finish) + return w +} + +func TestActorQueuedReplySuccessFinishesWithoutActorFeedback(t *testing.T) { + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + s.c = &queuedReplyWriteConn{fakeConn: s.c.(*fakeConn)} + reportingStartActor(t, a) + w := actorSelectedReply(t, a, s, watchdogRequest(t)) + defer w.replyDelivery.finish() + if len(w.replyDelivery.notifications) != 0 { + t.Fatal("ordinary DWA unexpectedly requires admission reporting") + } + + actorEntered := make(chan struct{}) + releaseActor, unblockActor := queuedReplyGate(t) + defer unblockActor() + a.post(event{testHook: func() { close(actorEntered); <-releaseActor }}) + actorDeliveryWait(t, actorEntered, "blocked actor") + + nextWrite := make(chan struct{}) + releaseNext, unblockNext := queuedReplyGate(t) + defer unblockNext() + marker := diam.NewMessage(999, 0, 0, 91, 92, a.m.dictionary()) + s.beforePublication = func(msg *diam.Message) { + if msg == marker { + close(nextWrite) + <-releaseNext + } + } + s.writes <- w + s.writes <- writeRequest{msg: marker} + done := orderedReplyWriter(t, s) + // The production writer reaches its next publication only after the DWA's + // empty delivery and successful operation completion have both finished. + actorDeliveryWait(t, nextWrite, "next write while actor is blocked") + if len(a.events) != 0 { + t.Fatal("successful queued DWA posted actor feedback") + } + s.coreMu.Lock() + writes, headers := len(s.coreWrites), len(s.coreHeaders) + s.coreMu.Unlock() + if writes != 0 || headers != 0 || s.protocolSnapshot().Phase() != protocol.Open { + t.Fatal("successful queued DWA retained writer ownership or changed phase") + } + unblockActor() + s.close() + unblockNext() + actorDeliveryWait(t, done, "successful writer exit") +} + +func TestActorQueuedReplyFailurePreservesBlockedQueuedNotice(t *testing.T) { + for _, result := range []string{"failed", "short"} { + t.Run(result, func(t *testing.T) { + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + c := &queuedReplyWriteConn{fakeConn: s.c.(*fakeConn), short: result == "short"} + if result == "failed" { + c.err = io.ErrClosedPipe + } + s.c = c + queued := make(chan struct{}) + releaseObserver, unblockObserver := queuedReplyGate(t) + defer unblockObserver() + events := make(chan diam.ProtocolErrorEvent, 4) + a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { + if e.Reply == diam.ProtocolReplyQueued { + close(queued) + <-releaseObserver + } + events <- e + } + reportingStartActor(t, a) + request := diam.NewMessage(0xeeee, diam.RequestFlag, 0, 71, 72, a.m.dictionary()) + w := actorSelectedReply(t, a, s, request) + defer w.replyDelivery.finish() + actorDeliveryWait(t, queued, "actor-owned queued observer") + s.writes <- w + done := orderedReplyWriter(t, s) + actorDeliveryWait(t, done, "failed writer while queued observer is blocked") + reportingAwaitReplyFailure(t, w.replyDelivery) + // Neither a blocked queued observer nor its deferred failure may own + // the actor. This fence uses the actual production runner. + actorDeliveryFence(t, a) + select { + case e := <-events: + t.Fatalf("failure overtook queued observer: %+v", e) + default: + } + unblockObserver() + for i, want := range []diam.ProtocolReplyStatus{diam.ProtocolReplyQueued, diam.ProtocolReplyFailed} { + e := orderedReplyEvent(t, events) + if e.Reply != want || !e.Message.HasHeader || e.Message.Header.HopByHopID != request.Header.HopByHopID || e.Message.Header.EndToEndID != request.Header.EndToEndID || e.ReplyCode != diam.CommandUnsupported { + t.Fatalf("notification %d lost order or correlation: %+v", i, e) + } + if i == 1 && (!e.Closing || !errors.Is(e.Err, diam.ProtocolReplyFailure)) { + t.Fatalf("failed reply lost typed closing result: %+v", e) + } + } + if len(events) != 0 { + t.Fatal("duplicate failed reply notification") + } + }) + } +} + +func TestActorDPRWriterRetainsControlUntilCompletion(t *testing.T) { + for _, completion := range []string{"early-DPA", "retirement"} { + t.Run(completion, func(t *testing.T) { + a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + a.cfg.NoAutoReconnect = true + releaseWrite, unblockWrite := queuedReplyGate(t) + defer unblockWrite() + enteredWrite := make(chan struct{}) + s.c = &queuedReplyWriteConn{fakeConn: s.c.(*fakeConn), entered: enteredWrite, release: releaseWrite} + a.m.mu.Lock() + a.m.sessions[s.c] = s + a.m.mu.Unlock() + reportingStartActor(t, a) + a.post(event{kind: stop, cause: sm.DisconnectRebooting}) + actorDeliveryFence(t, a) + w := requiredQueuedWrite(t, s) + if w.msg.Header.CommandCode != diam.DisconnectPeer || w.msg.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatal("Closing did not select DPR") + } + defer w.replyDelivery.finish() + s.writes <- w + done := orderedReplyWriter(t, s) + actorDeliveryWait(t, enteredWrite, "DPR transport completion barrier") + if completion == "early-DPA" { + answer, err := base.BuildDPA(w.msg, testBase(string(a.cfg.Host))) + if err != nil { + t.Fatal(err) + } + // Manager commits queue publication before processing a DPA. + // Its native writer can still own a transport completion pin. + a.post(event{kind: wireEvent, s: s, msg: answer}) + actorDeliveryFence(t, a) + } else { + retired := make(chan struct{}) + var retireErr error + go func() { + retireErr = a.m.HandleRetire(s.c, diam.SCTPAssociationRestart) + close(retired) + }() + actorDeliveryWait(t, retired, "retirement acknowledgement without joining writer") + if retireErr != nil { + t.Fatal(retireErr) + } + } + controls := s.protocolSnapshot().Controls() + if len(controls) != 1 || controls[0].Command != diam.DisconnectPeer || controls[0].Logical || controls[0].Writers != 1 { + t.Fatalf("terminal decision lost outstanding DPR writer: %+v", controls) + } + unblockWrite() + actorDeliveryWait(t, done, "terminal DPR writer exit") + actorDeliveryFence(t, a) + if controls := s.protocolSnapshot().Controls(); len(controls) != 0 { + t.Fatalf("completed DPR retained control ownership: %+v", controls) + } + }) + } +} diff --git a/diam/peer/renewal.go b/diam/peer/renewal.go index babece4..279d067 100644 --- a/diam/peer/renewal.go +++ b/diam/peer/renewal.go @@ -5,13 +5,14 @@ import ( "sync" "github.com/gomaja/go-diameter/diam" - "github.com/gomaja/go-diameter/diam/internal/watchdog" + "github.com/gomaja/go-diameter/diam/internal/protocol" ) // protocolDelivery reserves completion storage with the admitted work. The // actor commits without calling an observer; the reporting worker delivers // synchronously after releasing Server admission (RFC 6733 §§5.6, 5.6.1). type protocolDelivery struct { + priorReply *protocolDelivery done chan struct{} once sync.Once notifications []diam.ProtocolErrorEvent @@ -24,7 +25,11 @@ func newProtocolDelivery() *protocolDelivery { return &protocolDelivery{done: ma func (d *protocolDelivery) finish() { d.once.Do(func() { close(d.done) }) } func (d *protocolDelivery) deliver(m *Manager) { for _, notification := range d.notifications { - m.HandleProtocolError(notification) + if d.priorReply != nil { + d.priorReply.replyFailed(m, ¬ification) + } else { + m.HandleProtocolError(notification) + } } d.replyMu.Lock() d.delivered = true @@ -170,6 +175,9 @@ func (m *Manager) HandleRetire(c diam.Conn, reason diam.RetirementReason) error func (a *actor) retireResponder(cmd retirementCommand) { s := cmd.s _, gen := s.binding() + if s.core != nil { + s.applyProtocol(protocol.Input{Kind: protocol.Retire, Reason: cmd.reason}, nil) + } wasActive := a.active == s current := a.r == s if current { @@ -190,8 +198,7 @@ func (a *actor) retireResponder(cmd retirementCommand) { a.pendingDPR, a.pendingDPREnd = 0, 0 a.pendingDPRGen, a.pendingDPRCommand = 0, 0 if a.everOpen { - next, _ := a.watchdogCore().Step(watchdog.ConnectionDown) - a.watchdog = WatchdogState(next.State) + a.watchdog = WatchdogDown } a.scheduleReconnect() } diff --git a/diam/peer/renewal_close_test.go b/diam/peer/renewal_close_test.go index baadf31..7dc3250 100644 --- a/diam/peer/renewal_close_test.go +++ b/diam/peer/renewal_close_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) @@ -62,6 +63,8 @@ func TestManagerRenewalCloseWatcherJoinsInterveningRetirement(t *testing.T) { s.admitted.Store(true) a.r, a.active = s, s a.meta = &smpeer.Metadata{OriginHost: "known.example.net", OriginRealm: "example.net", Applications: []uint32{4}} + renewalCoreSession(t, s, true, true, "example.net") + a.syncProtocol(s, protocol.Input{Kind: protocol.ReleaseAdmission}) a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[c] = s @@ -117,6 +120,8 @@ func TestManagerRenewalTerminalDecisionVetoesRenewal(t *testing.T) { s := &session{m: m, c: c, actor: a, gen: 1, inbound: true, first: true, closed: make(chan struct{}), ingress: make(chan incoming, 4), writes: make(chan writeRequest, 4)} s.admitted.Store(true) a.r, a.active = s, s + renewalCoreSession(t, s, true, true, "example.net") + a.syncProtocol(s, protocol.Input{Kind: protocol.ReleaseAdmission}) a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[c] = s @@ -138,7 +143,7 @@ func TestManagerRenewalTerminalDecisionVetoesRenewal(t *testing.T) { closing = make(chan struct{}) go func() { s.close(); close(closing) }() renewalScriptReceive(t, c.closeEntered, "terminal Close blocked at transport") - } else if err := s.send(reviewCER(t).Answer(diam.InvalidHDRBits), true); err != nil { + } else if err := s.send(capabilitiesExchangeRequest(t).Answer(diam.InvalidHDRBits), true); err != nil { t.Fatal(err) } if c.Closed() { diff --git a/diam/peer/renewal_core_fixture_test.go b/diam/peer/renewal_core_fixture_test.go new file mode 100644 index 0000000..1a02d3c --- /dev/null +++ b/diam/peer/renewal_core_fixture_test.go @@ -0,0 +1,74 @@ +package peer + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +// renewalCoreSession stages a real connection core before publishing its actor. +// A private setup actor executes the normal admission/election effects so a +// candidate stays unadmitted and an initiator retains its deferred Start effects. +func renewalCoreSession(t *testing.T, s *session, started, admitted bool, realm string) { + t.Helper() + owner := s.actor + setup := &actor{m: s.m, cfg: owner.cfg, state: Closed, watchdog: WatchdogInitial, + events: make(chan event, 64), done: make(chan struct{}), nextGen: s.gen - 1, pendingGen: s.gen} + setup.publish(nil) + s.actor = setup + defer func() { s.actor = owner }() + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + if !s.inbound && !started { + return + } + remote := testBase(string(owner.cfg.Host)) + remote.OriginRealm = datatype.DiameterIdentity(realm) + if s.inbound { + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) + if !admitted { + setup.state = WaitConnAck + } + cer, err := base.BuildCER(s.m.dictionary(), remote) + if err != nil { + t.Fatal(err) + } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: cer}, nil) + if admitted { + w := renewalCoreWrite(t, s, diam.CapabilitiesExchange) + fixtureCompleteWrite(s, w) + } + } else { + setup.state = WaitConnAck + setup.onDial(event{gen: s.gen, s: s}) + w := renewalCoreWrite(t, s, diam.CapabilitiesExchange) + s.cerRequest = w.msg + s.cerHop, s.cerEnd = w.msg.Header.HopByHopID, w.msg.Header.EndToEndID + fixtureCompleteWrite(s, w) + if admitted { + cea, err := base.BuildCEA(w.msg, remote, diam.Success) + if err != nil { + t.Fatal(err) + } + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: cea}, nil) + } + } +} + +func renewalCoreWrite(t *testing.T, s *session, command uint32) writeRequest { + t.Helper() + select { + case w := <-s.writes: + if w.msg.Header.CommandCode != command { + t.Fatalf("fixture queued %d, want %d", w.msg.Header.CommandCode, command) + } + return w + default: + t.Fatalf("fixture did not queue %d", command) + return writeRequest{} + } +} diff --git a/diam/peer/renewal_script_test.go b/diam/peer/renewal_script_test.go index 0afeca3..45f9d6c 100644 --- a/diam/peer/renewal_script_test.go +++ b/diam/peer/renewal_script_test.go @@ -965,7 +965,7 @@ func TestManagerRenewalScriptTLSRestartClosesPhysical(t *testing.T) { t.Fatal("accepted transport did not complete real TLS handshake") } if admitted { - request := reviewCER(t) + request := capabilitiesExchangeRequest(t) if _, err := request.WriteTo(clientTLS); err != nil { t.Fatal(err) } @@ -1057,7 +1057,7 @@ func TestManagerRenewalScriptDiscardsQueuedOldWrites(t *testing.T) { hold.unblock() synctest.Wait() if hold.attempts.Load() != 1 || len(h.transport.writes) != 0 { - t.Fatal("queued retired writes were submitted after retirement") + t.Fatalf("queued retired writes were submitted after retirement: old transport attempts=%d, unread physical frames=%d", hold.attempts.Load(), len(h.transport.writes)) } if !pair.old.Closed() || pair.next.Closed() { t.Fatal("discarding queued writes affected replacement") diff --git a/diam/peer/renewal_unit_test.go b/diam/peer/renewal_unit_test.go index 2dda077..87d0149 100644 --- a/diam/peer/renewal_unit_test.go +++ b/diam/peer/renewal_unit_test.go @@ -17,6 +17,7 @@ import ( "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" "github.com/gomaja/go-diameter/diam/internal/logtest" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) @@ -54,7 +55,7 @@ func renewalCompositionActor(t *testing.T, state PeerState, local string, respon } a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: state, watchdog: WatchdogInitial, nextGen: 17, pendingGen: 11, dialing: state == WaitConnAck || state == WaitConnAckElect, events: make(chan event, 16), done: make(chan struct{})} i := &session{m: m, c: newFakeConn(testDict), actor: a, gen: 11, writes: make(chan writeRequest, 16), closed: make(chan struct{}), meta: &smpeer.Metadata{OriginHost: "known.example.net", OriginRealm: "initiator.example.net", Applications: []uint32{4}}} - r := &session{m: m, c: newFakeConn(testDict), actor: a, gen: 7, inbound: true, writes: make(chan writeRequest, 16), ingress: make(chan incoming, 16), closed: make(chan struct{}), cerRequest: reviewCER(t), meta: &smpeer.Metadata{OriginHost: "known.example.net", OriginRealm: "old.example.net", Applications: []uint32{4}}} + r := &session{m: m, c: newFakeConn(testDict), actor: a, gen: 7, inbound: true, writes: make(chan writeRequest, 16), ingress: make(chan incoming, 16), closed: make(chan struct{}), cerRequest: capabilitiesExchangeRequest(t), meta: &smpeer.Metadata{OriginHost: "known.example.net", OriginRealm: "old.example.net", Applications: []uint32{4}}} if state == WaitICEA || state == WaitReturns || activeI { a.i = i } @@ -73,19 +74,16 @@ func renewalCompositionActor(t *testing.T, state PeerState, local string, respon a.meta = i.meta.Clone() a.watchdog = WatchdogOkay a.everOpen = true - if state == IOpen { - a.armWatchdog() - } } - if state == WaitICEA || state == WaitReturns { - request, err := base.BuildCER(testDict, m.baseSettings(i.c)) - if err != nil { - t.Fatal(err) - } - i.cerRequest = request - i.cerHop = request.Header.HopByHopID - i.cerEnd = request.Header.EndToEndID + renewalCoreSession(t, r, true, activeR, "old.example.net") + renewalCoreSession(t, i, state == WaitICEA || state == WaitReturns || activeI, activeI, "initiator.example.net") + if a.active != nil { + a.syncProtocol(a.active, protocol.Input{Kind: protocol.ReleaseAdmission}) } + if state == Closing { + a.active.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + } + a.nextGen = 17 a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[r.c] = r @@ -125,8 +123,6 @@ func renewalCompositionCEA(t *testing.T, m *Manager, a *actor, i *session) { t.Fatal("initiator CER not queued") } } - request.Header.HopByHopID = i.cerHop - request.Header.EndToEndID = i.cerEnd cfg := testBase("known.example.net") cfg.OriginRealm = "initiator.example.net" cfg.AuthApplicationID = []*diam.AVP{diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))} @@ -249,15 +245,15 @@ func TestManagerRenewalWatchdogStatesEligibilityAndRetirement(t *testing.T) { t.Run(string(health), func(t *testing.T) { synctest.Test(t, func(t *testing.T) { m, a, _, old, clock := renewalCompositionActor(t, ROpen, "z.example.net", true, true, false) + timer := old.protocolSnapshot().Timer(protocol.WatchdogTimer) + renewalCompositionCommit(a, event{kind: coreEvent, s: old, input: protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.WatchdogTimer, Token: timer.Token, At: timer.Deadline}}) + fixtureCompleteWrite(old, renewalCoreWrite(t, old, diam.DeviceWatchdog)) + synctest.Wait() + // Eligibility is deliberately independent of the published health + // label; timer and control ownership remain the real core's. a.watchdog = health - a.pendingWatchdog = true - a.watchdogHop = 41 - a.watchdogEnd = 42 - a.watchdogGen = old.gen - a.numDWA = 2 - a.armWatchdog() - oldToken, oldTimer := a.wdToken, a.wdTimer a.publish(nil) + oldToken, oldTimer := old.protocolSnapshot().Timer(protocol.WatchdogTimer).Token, old.coreTimers[protocol.WatchdogTimer] if !m.CanRenewAcceptedCER(old.c) { t.Fatalf("admitted active R with %s was ineligible", health) } @@ -283,7 +279,7 @@ func TestManagerRenewalWatchdogStatesEligibilityAndRetirement(t *testing.T) { if a.watchdog != WatchdogReopen || a.snapshot().Eligible || !m.CanRenewAcceptedCER(freshConn) { t.Fatalf("replacement of %s did not enter renewable REOPEN", health) } - renewalCompositionCommit(a, event{kind: watchdogTimeout, gen: old.gen, token: oldToken}) + renewalCompositionCommit(a, event{kind: coreEvent, s: old, gen: old.gen, input: protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.WatchdogTimer, Token: oldToken}}) clock.mu.Lock() timers := len(clock.timers) clock.mu.Unlock() @@ -414,10 +410,14 @@ func TestManagerRenewalRejectedAdmissionWins(t *testing.T) { c := newFakeConn() s := &session{m: m, c: c, inbound: true, writes: make(chan writeRequest, 4), ingress: make(chan incoming, 4), closed: make(chan struct{})} m.sessions[c] = s + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) if malformed { - _ = m.answerMessageError(s, reviewCER(t), &diam.MessageError{ResultCode: diam.InvalidAVPLength}) + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: capabilitiesExchangeRequest(t), Error: &diam.MessageError{ResultCode: diam.InvalidAVPLength}, ErrorInput: true}, nil) } else { - m.processDIAM(s, reviewCER(t)) + m.processDIAM(s, capabilitiesExchangeRequest(t)) } if len(s.writes) != 1 { t.Fatal("rejection did not queue its CEA") @@ -473,26 +473,26 @@ func TestManagerRenewalCleanupBeforeAcknowledgement(t *testing.T) { func TestManagerRenewalStaleStateTimer(t *testing.T) { for _, state := range []PeerState{WaitICEA, Closing} { t.Run(string(state), func(t *testing.T) { - a, s, _ := newWatchdogTestActor(t, WatchdogOkay) - a.state = state - if state == WaitICEA { - a.i = s - a.r = nil - a.active = nil + a, s, _ := failureReportingActor(t) + a.state, a.i = WaitICEA, s + renewalCoreSession(t, s, true, state == Closing, "example.net") + timerKind := protocol.HandshakeTimer + if state == Closing { + a.active = s + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + timerKind = protocol.ClosingTimer } + a.publish(nil) notifications := make(chan diam.ProtocolErrorEvent, 2) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { notifications <- e } - a.arm(time.Second) - oldToken := a.token - a.arm(time.Second) - a.handle(event{kind: timeout, token: oldToken}) + timer := s.protocolSnapshot().Timer(timerKind) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: timerKind, Token: timer.Token + 1, At: timer.Deadline}, nil) if a.state != state || s.c.Closed() { t.Errorf("stale timer ended new %s exchange: state=%s closed=%t", state, a.state, s.c.Closed()) } if len(notifications) != 0 { t.Error("stale timer emitted a protocol notification") } - a.stopTimer() }) } } @@ -504,13 +504,15 @@ func TestManagerRenewalRetirementDuringInitiatorCER(t *testing.T) { t.Fatal(err) } a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitConnAckElect, watchdog: WatchdogInitial, events: make(chan event, 4), done: make(chan struct{}), pendingGen: 7, nextGen: 7, dialing: true} - r := &session{m: m, c: newFakeConn(), actor: a, gen: 6, inbound: true, closed: make(chan struct{}), writes: make(chan writeRequest, 4), cerRequest: reviewCER(t)} + r := &session{m: m, c: newFakeConn(), actor: a, gen: 6, inbound: true, closed: make(chan struct{}), writes: make(chan writeRequest, 4), cerRequest: capabilitiesExchangeRequest(t)} i := &session{m: m, c: newFakeConn(), actor: a, gen: 7, closed: make(chan struct{}), writes: make(chan writeRequest, 4)} a.r = r a.meta = &smpeer.Metadata{OriginHost: "known.example.net", OriginRealm: "example.net", Applications: []uint32{4}} a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[r.c] = r + renewalCoreSession(t, r, true, false, "example.net") + renewalCoreSession(t, i, false, false, "example.net") entered, release := make(chan struct{}), make(chan struct{}) i.beforeAdmission = func() { close(entered); <-release } m.wg.Add(1) @@ -649,9 +651,9 @@ func TestManagerRenewalRetirementStateTable(t *testing.T) { if err != nil { t.Fatal(err) } - a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: tc.state, watchdog: tc.watchdog, everOpen: tc.activeR || tc.activeI, nextGen: 17, pendingGen: 11, dialing: tc.state == WaitConnAckElect, events: make(chan event, 1), done: make(chan struct{}), pendingDPR: 41, pendingDPREnd: 42, pendingWatchdog: true, watchdogHop: 43, watchdogEnd: 44, watchdogGen: 7, numDWA: 2} + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: tc.state, watchdog: tc.watchdog, everOpen: tc.activeR || tc.activeI, nextGen: 17, pendingGen: 11, dialing: tc.state == WaitConnAckElect, events: make(chan event, 1), done: make(chan struct{})} i := &session{m: m, c: newFakeConn(), actor: a, gen: 11, closed: make(chan struct{}), writes: make(chan writeRequest, 4)} - r := &session{m: m, c: newFakeConn(), actor: a, gen: 7, inbound: true, closed: make(chan struct{}), writes: make(chan writeRequest, 4), cerRequest: reviewCER(t), cerHop: 31, cerEnd: 32} + r := &session{m: m, c: newFakeConn(), actor: a, gen: 7, inbound: true, closed: make(chan struct{}), writes: make(chan writeRequest, 4), cerRequest: capabilitiesExchangeRequest(t), cerHop: 31, cerEnd: 32} a.i = i if tc.responder { a.r = r @@ -663,12 +665,27 @@ func TestManagerRenewalRetirementStateTable(t *testing.T) { a.active = i } a.meta = &smpeer.Metadata{OriginHost: a.cfg.Host, OriginRealm: "example.net", Applications: []uint32{4}} + renewalCoreSession(t, r, true, tc.activeR, "example.net") + renewalCoreSession(t, i, tc.state == WaitICEA || tc.state == WaitReturns || tc.activeI, tc.activeI, "example.net") + if a.active != nil { + a.syncProtocol(a.active, protocol.Input{Kind: protocol.ReleaseAdmission}) + fixtureExpireWatchdog(a) + fixtureConsumeProbe(a.active) + if tc.watchdog == WatchdogSuspect { + fixtureExpireWatchdog(a) + } + if tc.state == Closing { + a.active.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + } + } + a.nextGen = 17 + surviving := i.protocolSnapshot() a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[r.c] = r m.nextHop = 88 oldEnd := m.endToEnd.next.Load() - a.events <- event{kind: wireEvent, s: r, msg: reviewCER(t)} + a.events <- event{kind: wireEvent, s: r, msg: capabilitiesExchangeRequest(t)} retired := make(chan error, 1) go func() { retired <- m.HandleRetire(r.c, diam.SCTPAssociationRestart) }() select { @@ -701,7 +718,7 @@ func TestManagerRenewalRetirementStateTable(t *testing.T) { if tc.activeR && (a.pendingWatchdog || a.watchdogHop != 0 || a.pendingDPR != 0 || a.numDWA != 0) { t.Error("retirement retained watchdog/disconnect identifiers") } - if tc.activeI && (a.watchdogHop != 43 || a.pendingDPR != 41 || a.numDWA != 2) { + if tc.activeI && (!reflect.DeepEqual(i.protocolSnapshot().Controls(), surviving.Controls()) || i.protocolSnapshot().Watchdog() != surviving.Watchdog()) { t.Error("candidate retirement reset surviving initiator identifiers") } if r.cerRequest != nil || r.cerHop != 0 || r.cerEnd != 0 { @@ -727,13 +744,21 @@ func TestManagerRenewalStaleTimerTokens(t *testing.T) { for _, state := range []PeerState{WaitICEA, Closing} { t.Run(string(state), func(t *testing.T) { a, s, _ := failureReportingActor(t) - a.state, a.i, a.active, a.token = state, s, s, 20 + a.state, a.i = WaitICEA, s + renewalCoreSession(t, s, true, state == Closing, "example.net") + timerKind := protocol.HandshakeTimer + if state == Closing { + a.active = s + s.applyProtocol(protocol.Input{Kind: protocol.Disconnect}, nil) + timerKind = protocol.ClosingTimer + } a.publish(nil) - a.handle(event{kind: timeout, token: 19}) + timer := s.protocolSnapshot().Timer(timerKind) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: timerKind, Token: timer.Token + 1, At: timer.Deadline}, nil) if a.state != state || s.c.Closed() { t.Fatalf("stale timer closed current session: state=%s closed=%t", a.state, s.c.Closed()) } - a.handle(event{kind: timeout, token: 20}) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: timerKind, Token: timer.Token, At: timer.Deadline}, nil) if a.state != Closed || !s.c.Closed() { t.Fatal("current timer did not close waiting session") } @@ -741,13 +766,17 @@ func TestManagerRenewalStaleTimerTokens(t *testing.T) { } t.Run("watchdog", func(t *testing.T) { a, s, _ := failureReportingActor(t) - a.state, a.r, a.active, a.watchdog, a.wdToken = ROpen, s, s, WatchdogOkay, 20 + s.inbound = true + a.state, a.r, a.active = ROpen, s, s + renewalCoreSession(t, s, true, true, "example.net") + a.syncProtocol(s, protocol.Input{Kind: protocol.ReleaseAdmission}) a.publish(nil) - a.handle(event{kind: watchdogTimeout, token: 19, gen: s.gen}) + timer := s.protocolSnapshot().Timer(protocol.WatchdogTimer) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.WatchdogTimer, Token: timer.Token + 1, At: timer.Deadline}, nil) if len(s.writes) != 0 || a.pendingWatchdog { t.Fatal("stale watchdog timer sent current-generation probe") } - a.handle(event{kind: watchdogTimeout, token: 20, gen: s.gen}) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: protocol.WatchdogTimer, Token: timer.Token, At: timer.Deadline}, nil) if len(s.writes) != 1 || !a.pendingWatchdog { t.Fatal("current watchdog timer did not send probe") } diff --git a/diam/peer/reporting_core_fixture_test.go b/diam/peer/reporting_core_fixture_test.go new file mode 100644 index 0000000..90ea19f --- /dev/null +++ b/diam/peer/reporting_core_fixture_test.go @@ -0,0 +1,165 @@ +package peer + +import ( + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +func reportingOpen(t *testing.T, s *session) { + t.Helper() + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } +} + +// Real initiator startup reserves its CER coordinates before the fixture +// supplies a CEA. It leaves the selected CER queued for failure injection when +// publish is false, and otherwise completes that writer before returning. +func reportingInitiator(t *testing.T, a *actor, s *session, publish bool) *diam.Message { + t.Helper() + s.inbound = false + a.state, a.i, a.r, a.active = WaitICEA, s, nil, nil + if a.events == nil { + a.events = make(chan event, 64) + } + if a.done == nil { + a.done = make(chan struct{}) + } + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) + if !publish { + return nil + } + w := requiredQueuedWrite(t, s) + if w.msg.Header.CommandCode != diam.CapabilitiesExchange || w.msg.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("startup selected %+v", w.msg.Header) + } + fixtureCompleteWrite(s, w) + return w.msg +} + +func reportingResponder(t *testing.T, a *actor, s *session) { + t.Helper() + s.inbound = true + a.state, a.r, a.i, a.active = Closed, nil, nil, nil + if a.events == nil { + a.events = make(chan event, 64) + } + if a.done == nil { + a.done = make(chan struct{}) + } + if err := s.initProtocol(); err != nil { + t.Fatal(err) + } + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) +} + +// Holding the responder in the actor's election state produces a validated +// candidate without selecting CEA, exactly as concurrent dialing does. +func reportingCandidate(t *testing.T, a *actor, s *session) *diam.Message { + t.Helper() + reportingResponder(t, a, s) + a.state = WaitConnAck + request, err := base.BuildCER(s.m.dictionary(), testBase(string(a.cfg.Host))) + if err != nil { + t.Fatal(err) + } + a.onWire(event{s: s, msg: request}) + // Before the first CER, this session has no actor responder slot. + if s.candidate == 0 { + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: request}, nil) + } + if s.candidate == 0 || len(s.writes) != 0 { + t.Fatalf("election failed to hold validated CER: candidate=%d writes=%d", s.candidate, len(s.writes)) + } + return request +} + +func reportingWriteOne(t *testing.T, s *session, w writeRequest) bool { + t.Helper() + stopped := s.writeOne(w) + if s.actor != nil { + for len(s.actor.events) > 0 { + s.actor.handle(<-s.actor.events) + } + } + return stopped || s.c.Closed() +} + +// The normal actor runner supplies serialization and the separate observer +// boundary used by concurrent writer/callback tests. +func reportingStartActor(t *testing.T, a *actor) { + t.Helper() + m := a.m + m.mu.Lock() + m.peers[identity(a.cfg.Host)] = a + m.wg.Add(1) + m.mu.Unlock() + go a.run() + t.Cleanup(func() { + if a.activeSession.Load() != nil { + a.post(event{kind: connGone, s: a.activeSession.Load()}) + } + closeManager(t, m, nil) + }) +} + +func reportingReceive(a *actor, s *session, msg *diam.Message, d *protocolDelivery) { + owned := d == nil + if owned { + d = newProtocolDelivery() + } + a.commit(event{kind: wireEvent, s: s, msg: msg}, d) + if owned { + d.deliver(s.m) + } +} + +func reportingRejectCandidate(s *session, msg *diam.Message, code uint32, d *protocolDelivery) { + s.applyProtocol(protocol.Input{Kind: protocol.RejectCandidate, Token: s.candidate, Message: msg, ResultCode: code}, d) +} + +func newReportingRawActor(t *testing.T) (*actor, *session, *fakeClock) { + t.Helper() + clock := &fakeClock{} + m, err := New(Config{Settings: testSettings("local.example.net"), Clock: clock}) + if err != nil { + t.Fatal(err) + } + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: Closed, watchdog: WatchdogInitial, events: make(chan event, 64), done: make(chan struct{})} + s := &session{m: m, c: newFakeConn(), actor: a, gen: 1, writes: make(chan writeRequest, 8), closed: make(chan struct{})} + a.publish(nil) + t.Cleanup(func() { s.close(); closeManager(t, m, nil) }) + return a, s, clock +} + +func reportingExpire(s *session, kind protocol.TimerKind) { + timer := s.protocolSnapshot().Timer(kind) + s.applyProtocol(protocol.Input{Kind: protocol.TimerExpired, Timer: kind, Token: timer.Token, At: timer.Deadline}, nil) +} + +// A writer's return precedes its actor completion and observer delivery. Wait +// for the production reply-ordering slot to contain that completion before +// asserting the admission observer still owns delivery. +func reportingAwaitReplyFailure(t *testing.T, d *protocolDelivery) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + d.replyMu.Lock() + ready := d.failedReply != nil + d.replyMu.Unlock() + if ready { + return + } + if time.Now().After(deadline) { + t.Fatal("writer completion did not reach the admission delivery slot") + } + time.Sleep(time.Millisecond) + } +} diff --git a/diam/peer/reporting_test.go b/diam/peer/reporting_test.go index 354892d..cb6635e 100644 --- a/diam/peer/reporting_test.go +++ b/diam/peer/reporting_test.go @@ -83,7 +83,7 @@ func TestManagerLogsEveryUnhandledMessage(t *testing.T) { t.Fatal(err) } defer func() { _ = c.Close() }() - write(t, c, reviewCER(t)) + write(t, c, capabilitiesExchangeRequest(t)) if code(t, read(t, c)) != diam.Success { t.Fatal("handshake failed") } @@ -97,7 +97,7 @@ func TestManagerLogsEveryUnhandledMessage(t *testing.T) { write(t, c, req.Answer(diam.Success)) } // DWA is a wire barrier after the final answer was admitted to the actor. - write(t, c, reviewDWR(t)) + write(t, c, watchdogRequest(t)) read(t, c) got := records.Records() if len(got) != 2*total { @@ -202,10 +202,13 @@ func TestManagerLogsBeforeClosingWaitICEA(t *testing.T) { defer func() { _ = c.other.Close() }() s := &session{m: m, c: c, gen: 1, writes: make(chan writeRequest, 8), closed: make(chan struct{})} a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: WaitICEA, i: s, events: make(chan event, 8)} + s.actor = a + a.publish(nil) + reportingInitiator(t, a, s, false) if err = c.other.SetReadDeadline(time.Now().Add(time.Second)); err != nil { t.Fatal(err) } - a.onWire(event{kind: wireEvent, s: s, msg: reviewDWR(t)}) + a.onWire(event{kind: wireEvent, s: s, msg: watchdogRequest(t)}) var b [1]byte if _, err = c.other.Read(b[:]); !errors.Is(err, io.EOF) { t.Fatalf("expected EOF: %v", err) diff --git a/diam/peer/request_completion_wire_test.go b/diam/peer/request_completion_wire_test.go index fc074cc..d773184 100644 --- a/diam/peer/request_completion_wire_test.go +++ b/diam/peer/request_completion_wire_test.go @@ -107,7 +107,7 @@ func TestManagerCompletedSelectedRequestOnWire(t *testing.T) { } // A following answer joins this writer without creating new request // ownership; receipt proves the selected slot has been drained. - sentinel, err := base.BuildDWA(reviewDWR(t), m.baseSettings(s.c)) + sentinel, err := base.BuildDWA(watchdogRequest(t), m.baseSettings(s.c)) if err != nil { t.Fatal(err) } diff --git a/diam/peer/request_gate_wire_test.go b/diam/peer/request_gate_wire_test.go index 7e9b6f4..ac62428 100644 --- a/diam/peer/request_gate_wire_test.go +++ b/diam/peer/request_gate_wire_test.go @@ -99,7 +99,7 @@ func TestManagerCompletedRequestWaitingForNativeGateOnWire(t *testing.T) { close(entered) } } - held, err := base.BuildDWA(reviewDWR(t), m.baseSettings(s.c)) + held, err := base.BuildDWA(watchdogRequest(t), m.baseSettings(s.c)) if err != nil { t.Fatal(err) } @@ -142,7 +142,7 @@ func TestManagerCompletedRequestWaitingForNativeGateOnWire(t *testing.T) { } time.Sleep(time.Millisecond) } - sentinel, err := base.BuildDWA(reviewDWR(t), m.baseSettings(s.c)) + sentinel, err := base.BuildDWA(watchdogRequest(t), m.baseSettings(s.c)) if err != nil { t.Fatal(err) } diff --git a/diam/peer/required_reply.go b/diam/peer/required_reply.go index 486ba0e..24b86f4 100644 --- a/diam/peer/required_reply.go +++ b/diam/peer/required_reply.go @@ -16,30 +16,3 @@ func requiredReplyEvent(c diam.Conn, request *diam.Message, code uint32) *diam.P } return e } - -func (s *session) sendRequired(answer, request *diam.Message, closeAfter bool, code uint32, delivery ...*protocolDelivery) error { - w := writeRequest{msg: answer, closeAfter: closeAfter, required: true} - if s.m.ProtocolErrorEnabled() { - w.reply = requiredReplyEvent(s.c, request, code) - if len(delivery) != 0 { - w.replyDelivery = delivery[0] - } - } - return s.sendWrite(w) -} - -// Calls outside actor admission still report their queue decision before a -// concurrent writer reports failure. The actor's delivery keeps its existing -// boundary after Server admission is released, so observers can close Conn. -func (m *Manager) requiredReplyDelivery(delivery []*protocolDelivery) ([]*protocolDelivery, func()) { - if !m.ProtocolErrorEnabled() || len(delivery) != 0 && delivery[0] != nil { - return delivery, func() {} - } - d := newProtocolDelivery() - return []*protocolDelivery{d}, func() { d.deliver(m) } -} - -func (a *actor) requiredReplyFailed(s *session, request *diam.Message, code uint32) { - s.close() - a.protocolError(s.c, request, diam.ProtocolReplyFailure, 0, true, diam.ProtocolReplyFailed, code) -} diff --git a/diam/peer/required_reply_order_test.go b/diam/peer/required_reply_order_test.go index 7042595..dcc6596 100644 --- a/diam/peer/required_reply_order_test.go +++ b/diam/peer/required_reply_order_test.go @@ -8,7 +8,6 @@ import ( "time" "github.com/gomaja/go-diameter/diam" - "github.com/gomaja/go-diameter/diam/internal/base" ) type orderedReplyConn struct { @@ -40,24 +39,21 @@ func orderedReplyRejection(t *testing.T, m *Manager, s *session, command string, msg := diam.NewMessage(0xeeee, diam.RequestFlag, 0, 11, 12, m.dictionary()) switch command { case "unsupported": - m.unsupported(s, msg, d) + reportingReceive(s.actor, s, msg, d) case "rejection": - msg = reviewDWR(t) - m.rejectRequest(s, msg, &base.RequestError{Kind: diam.ProtocolInvalidRequest, Code: diam.MissingAVP}, false, false, d) + msg = watchdogRequest(t) + msg.AVP = nil + reportingReceive(s.actor, s, msg, d) case "malformed": - msg = reviewDWR(t) - if err := m.answerMessageError(s, msg, &diam.MessageError{ResultCode: diam.InvalidAVPLength}, d); err != nil { - t.Fatal(err) - } + msg = watchdogRequest(t) + s.actor.commit(event{kind: wireEvent, s: s, msg: msg, messageErr: &diam.MessageError{ResultCode: diam.InvalidAVPLength}}, d) case "malformed_pre_CER": s.inbound = true - msg = reviewCER(t) - if err := m.answerMessageError(s, msg, &diam.MessageError{ResultCode: diam.InvalidAVPLength}, d); err != nil { - t.Fatal(err) - } + msg = capabilitiesExchangeRequest(t) + s.actor.commit(event{kind: wireEvent, s: s, msg: msg, messageErr: &diam.MessageError{ResultCode: diam.InvalidAVPLength}}, d) case "rejected_CEA": - msg = reviewCER(t) - m.rejectCER(s, msg, diam.NoCommonApplication, errors.New("no common application"), d) + msg = s.cerRequest + s.actor.commit(event{testHook: func() { reportingRejectCandidate(s, msg, diam.NoCommonApplication, d) }}, d) default: t.Fatal("unknown rejection") } @@ -89,6 +85,16 @@ func TestRequiredReplyConcurrentCompletionWaitsForAdmission(t *testing.T) { for _, command := range []string{"unsupported", "rejection", "malformed", "malformed_pre_CER", "rejected_CEA"} { t.Run(command, func(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + if command == "malformed_pre_CER" || command == "rejected_CEA" { + a, s, _ = newReportingRawActor(t) + if command == "rejected_CEA" { + reportingCandidate(t, a, s) + } else { + reportingResponder(t, a, s) + a.r = s + } + } + reportingStartActor(t, a) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), wrote: make(chan struct{})} s.c = c events := make(chan diam.ProtocolErrorEvent, 4) @@ -98,6 +104,7 @@ func TestRequiredReplyConcurrentCompletionWaitsForAdmission(t *testing.T) { msg := orderedReplyRejection(t, a.m, s, command, d) original := *msg.Header orderedReplyWait(t, done, "failed writer without admission observer") + reportingAwaitReplyFailure(t, d) select { case e := <-events: t.Fatalf("completion overtook queued admission: reply=%v closing=%t", e.Reply, e.Closing) @@ -128,6 +135,7 @@ func TestRequiredReplyConcurrentCompletionWaitsForAdmission(t *testing.T) { func TestRequiredReplyConcurrentCompletionWaitsForBlockedObserver(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) releaseWrite := make(chan struct{}) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), release: releaseWrite, wrote: make(chan struct{})} s.c = c @@ -151,6 +159,7 @@ func TestRequiredReplyConcurrentCompletionWaitsForBlockedObserver(t *testing.T) orderedReplyWait(t, queued, "queued observer") close(releaseWrite) orderedReplyWait(t, done, "failed writer while observer blocked") + reportingAwaitReplyFailure(t, d) select { case e := <-events: close(releaseObserver) @@ -180,6 +189,7 @@ func orderedReplyEvent(t *testing.T, events <-chan diam.ProtocolErrorEvent) diam func TestRequiredReplyConcurrentCompletionAfterAdmission(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) release := make(chan struct{}) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), release: release, wrote: make(chan struct{})} s.c = c @@ -204,6 +214,7 @@ func TestRequiredReplyConcurrentCompletionAfterAdmission(t *testing.T) { func TestRequiredReplyConcurrentSuccessDoesNotReportFailure(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), wrote: make(chan struct{}), success: true} s.c = c events := make(chan diam.ProtocolErrorEvent, 4) @@ -226,6 +237,7 @@ func TestRequiredReplyConcurrentSuccessDoesNotReportFailure(t *testing.T) { func TestRequiredReplyConcurrentCompletionWithoutDeferredDelivery(t *testing.T) { for range 32 { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), wrote: make(chan struct{})} s.c = c events := make(chan diam.ProtocolErrorEvent, 4) @@ -243,6 +255,7 @@ func TestRequiredReplyConcurrentCompletionWithoutDeferredDelivery(t *testing.T) func TestRequiredReplyConcurrentCompletionWithoutObserver(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) c := &orderedReplyConn{fakeConn: s.c.(*fakeConn), wrote: make(chan struct{})} s.c = c done := orderedReplyWriter(t, s) @@ -255,6 +268,7 @@ func TestRequiredReplyConcurrentCompletionWithoutObserver(t *testing.T) { func TestRequiredReplyConcurrentLeaseCompletionWaitsForAdmission(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) ready, release := make(chan struct{}), make(chan struct{}) s.beforePublication = func(*diam.Message) { close(ready); <-release } events := make(chan diam.ProtocolErrorEvent, 4) @@ -266,6 +280,7 @@ func TestRequiredReplyConcurrentLeaseCompletionWaitsForAdmission(t *testing.T) { a.discovery.Store(&discoveryState{revoked: true}) close(release) orderedReplyWait(t, done, "lease rejected writer") + reportingAwaitReplyFailure(t, d) select { case e := <-events: t.Fatalf("lease completion overtook admission: reply=%v", e.Reply) @@ -281,10 +296,15 @@ func TestRequiredReplyConcurrentLeaseCompletionWaitsForAdmission(t *testing.T) { func TestRequiredReplyBlockedObserverDoesNotDelayAnotherReply(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + reportingStartActor(t, a) first := &orderedReplyConn{fakeConn: s.c.(*fakeConn), wrote: make(chan struct{})} s.c = first - second := &orderedReplyConn{fakeConn: newFakeConn(), wrote: make(chan struct{})} - s2 := &session{m: a.m, c: second, writes: make(chan writeRequest, 8), closed: make(chan struct{})} + a2, s2, _ := newWatchdogTestActor(t, WatchdogOkay) + second := &orderedReplyConn{fakeConn: s2.c.(*fakeConn), wrote: make(chan struct{})} + s2.c = second + // Independent actors share the protocol observer without sharing a mutation lock. + a2.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { a.m.HandleProtocolError(e) } + reportingStartActor(t, a2) queued, release := make(chan struct{}), make(chan struct{}) events := make(chan diam.ProtocolErrorEvent, 8) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { diff --git a/diam/peer/required_reply_test.go b/diam/peer/required_reply_test.go index bd4e811..37f2493 100644 --- a/diam/peer/required_reply_test.go +++ b/diam/peer/required_reply_test.go @@ -8,6 +8,7 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/protocol" ) type requiredReplyConn struct { @@ -34,20 +35,31 @@ func TestRequiredReplyFailureClosesManager(t *testing.T) { } t.Run(command+"/"+failure, func(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) + var candidate *diam.Message + if command == "CEA" || command == "rejected_CEA" { + a, s, _ = failureReportingActor(t) + if failure == "build" { + a.m.cfg.Settings.HostIPAddresses = []datatype.Address{{}} + } + candidate = reportingCandidate(t, a, s) + } m := a.m c := &requiredReplyConn{fakeConn: s.c.(*fakeConn), failure: failure} s.c = c s.inbound = true s.admitted.Store(true) a.cfg.NoAutoReconnect = true + if m.sessions == nil { + m.sessions = make(map[diam.Conn]*session) + } m.sessions[c] = s - if !m.CanRenewAcceptedCER(c) { + if command != "CEA" && command != "rejected_CEA" && !m.CanRenewAcceptedCER(c) { t.Fatal("test connection was not eligible for renewal before the failure") } events := make(chan diam.ProtocolErrorEvent, 8) m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } m.cfg.Settings.HostIPAddresses = testBase("local.example.net").HostIPAddresses - msg := reviewDWR(t) + msg := watchdogRequest(t) code := uint32(diam.Success) if failure == "queue" { for len(s.writes) < cap(s.writes) { @@ -59,38 +71,43 @@ func TestRequiredReplyFailureClosesManager(t *testing.T) { } switch command { case "CEA": - msg = reviewCER(t) + msg = candidate s.cerRequest = msg a.sendCEA(s) case "rejected_CEA": - msg = reviewCER(t) + msg = candidate code = diam.NoCommonApplication - m.rejectCER(s, msg, code, errors.New("no common application")) + reportingRejectCandidate(s, msg, code, nil) case "DWA": - a.step(rDWR, s, msg) + a.onWire(event{s: s, msg: msg}) case "DPA": msg, _ = base.BuildDPR(msg.Dictionary(), testBase("known.example.net"), 0) a.answerDPR(s, msg) case "unsupported": msg = diam.NewMessage(0xeeee, diam.RequestFlag, 0, 11, 12, msg.Dictionary()) code = diam.CommandUnsupported - m.unsupported(s, msg) + a.onWire(event{s: s, msg: msg}) case "rejection", "malformed": code = diam.MissingAVP if failure == "build" { msg = diam.NewMessage(0xeeee, diam.RequestFlag, 0, 11, 12, msg.Dictionary()) } if command == "rejection" { - m.rejectRequest(s, msg, &base.RequestError{Kind: diam.ProtocolInvalidRequest, Code: code}, false, false) + if failure == "build" { + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: msg, Error: &diam.MessageError{ResultCode: code}, ErrorInput: true}, nil) + } else { + msg.AVP = nil + a.onWire(event{s: s, msg: msg}) + } } else { s.first = true - _ = m.answerMessageError(s, msg, &diam.MessageError{ResultCode: code}) + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: msg, Error: &diam.MessageError{ResultCode: code}, ErrorInput: true}, nil) } } if failure == "short" || failure == "write" { select { case w := <-s.writes: - if !s.writeOne(w) { + if !reportingWriteOne(t, s, w) { t.Error("failed required answer did not stop its writer") } default: @@ -140,8 +157,8 @@ func TestRequiredReplyShortWriteWithoutObserver(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) c := &requiredReplyConn{fakeConn: s.c.(*fakeConn), failure: "short"} s.c = c - a.step(rDWR, s, reviewDWR(t)) - if !s.writeOne(requiredQueuedWrite(t, s)) || !c.Closed() || c.closes != 1 { + a.onWire(event{s: s, msg: watchdogRequest(t)}) + if !reportingWriteOne(t, s, requiredQueuedWrite(t, s)) || !c.Closed() || c.closes != 1 { t.Fatalf("short required reply without observer: closed=%t closes=%d", c.Closed(), c.closes) } } @@ -152,12 +169,12 @@ func TestRequiredReplyCompletionKeepsRequestMetadata(t *testing.T) { s.c = c events := make(chan diam.ProtocolErrorEvent, 2) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } - msg := reviewDWR(t) + msg := watchdogRequest(t) original := *msg.Header - a.step(rDWR, s, msg) + a.onWire(event{s: s, msg: msg}) msg.Header.HopByHopID++ msg.AVP = nil - if !s.writeOne(requiredQueuedWrite(t, s)) { + if !reportingWriteOne(t, s, requiredQueuedWrite(t, s)) { t.Fatal("failed required reply retained its writer") } var e diam.ProtocolErrorEvent @@ -186,10 +203,10 @@ func TestRequiredReplyLeaseRejectionReportsCompletion(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogOkay) events := make(chan diam.ProtocolErrorEvent, 2) a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } - msg := reviewDWR(t) - a.step(rDWR, s, msg) + msg := watchdogRequest(t) + a.onWire(event{s: s, msg: msg}) a.discovery.Store(&discoveryState{revoked: true}) - if !s.writeOne(requiredQueuedWrite(t, s)) || !s.c.Closed() { + if !reportingWriteOne(t, s, requiredQueuedWrite(t, s)) || !s.c.Closed() { t.Fatal("expired lease retained the queued required answer") } select { diff --git a/diam/peer/restart_fallback_test.go b/diam/peer/restart_fallback_test.go index 79286af..83d734a 100644 --- a/diam/peer/restart_fallback_test.go +++ b/diam/peer/restart_fallback_test.go @@ -88,7 +88,7 @@ func TestManagerSecondCERKeepsConnectionOpen(t *testing.T) { if request.Header.CommandCode != 272 || request.Header.ApplicationID != 4 || request.Header.CommandFlags&diam.RequestFlag == 0 { t.Fatalf("application request=%+v", request.Header) } - second := reviewCER(t) + second := capabilitiesExchangeRequest(t) write(t, c, second) e := protocolErrorReceive(t, events) if err = c.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { @@ -109,7 +109,7 @@ func TestManagerSecondCERKeepsConnectionOpen(t *testing.T) { t.Fatalf("second CER completed pending request: %+v", got) default: } - dwr := reviewDWR(t) + dwr := watchdogRequest(t) write(t, c, dwr) dwa := read(t, c) if code(t, dwa) != diam.Success || dwa.Header.CommandCode != diam.DeviceWatchdog || dwa.Header.CommandFlags&diam.RequestFlag != 0 || dwa.Header.HopByHopID != dwr.Header.HopByHopID || dwa.Header.EndToEndID != dwr.Header.EndToEndID { @@ -180,9 +180,9 @@ func TestManagerSecondCERPreservesPendingRequests(t *testing.T) { m.pendingMu.Unlock() a.sendWatchdog() probe := nextWrite(t, s) - a.armWatchdog() + // The probe publication arms the core timer. before, meta, connContext := a.snapshot(), a.meta, s.c.Context() - second := reviewCER(t) + second := capabilitiesExchangeRequest(t) a.onWire(event{kind: wireEvent, s: s, msg: second}) if s.c.Closed() || a.state != state || a.active != s || a.meta != meta || s.c.Context() != connContext || !reflect.DeepEqual(a.snapshot(), before) { t.Fatal("second CER changed the admitted session") diff --git a/diam/peer/send_test.go b/diam/peer/send_test.go index 22c3dfe..099db4e 100644 --- a/diam/peer/send_test.go +++ b/diam/peer/send_test.go @@ -49,6 +49,11 @@ func outboundTestManager(t *testing.T) (*Manager, []*actor, []*session) { s := &session{m: m, c: c, actor: a, gen: uint64(i + 1), writes: make(chan writeRequest, 8), closed: make(chan struct{})} a.active, a.i = s, s a.publish(nil) + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } + a.state, a.i, a.r, a.active = IOpen, s, nil, s + a.publish(nil) close(a.done) m.peers[identity(host)] = a actors, sessions = append(actors, a), append(sessions, s) @@ -550,17 +555,12 @@ func TestCustomEndToEndErrorPreventsAdmission(t *testing.T) { } func TestManagedHopSkipsOutstandingControlRequest(t *testing.T) { - testDict := newTestDictionary(creditcontrol.Dictionary()) - - m, _, sessions := outboundTestManager(t) + m, actors, sessions := outboundTestManager(t) if err := m.SetRoutes([]Route{{Realm: "example.net", ApplicationID: 4, PeerHosts: []datatype.DiameterIdentity{"a.example.net"}}}); err != nil { t.Fatal(err) } - control := diam.NewRequest(diam.DeviceWatchdog, 0, testDict) - if err := sessions[0].sendControl(control); err != nil { - t.Fatal("control request not queued") - } - _ = nextWrite(t, sessions[0]) + fixtureExpireWatchdog(actors[0]) + control := nextWrite(t, sessions[0]) atomic.StoreUint32(&m.nextHop, control.Header.HopByHopID-1) result := sendAsync(m, context.Background(), outboundRequest("", "example.net")) business := nextWrite(t, sessions[0]) @@ -574,13 +574,12 @@ func TestManagedHopSkipsOutstandingControlRequest(t *testing.T) { } func TestControlReservationReleasedIfSessionClosed(t *testing.T) { - testDict := newTestDictionary(creditcontrol.Dictionary()) - - m, _, sessions := outboundTestManager(t) + m, actors, sessions := outboundTestManager(t) s := sessions[0] s.close() for range 64 { - if err := s.sendControl(diam.NewRequest(diam.DeviceWatchdog, 0, testDict)); err == nil { + fixtureExpireWatchdog(actors[0]) + if len(s.writes) != 0 { t.Fatal("closed session accepted control request") } if len(m.controls[s]) != 0 { diff --git a/diam/peer/session.go b/diam/peer/session.go index 1d7e67a..b167637 100644 --- a/diam/peer/session.go +++ b/diam/peer/session.go @@ -10,18 +10,21 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) var errQueueFull = errors.New("peer: queue full") type writeRequest struct { - msg *diam.Message - closeAfter bool - pending *pendingRequest - required bool - reply *diam.ProtocolErrorEvent - replyDelivery *protocolDelivery + protocolToken uint64 + protocolSource protocol.Input + msg *diam.Message + closeAfter bool + pending *pendingRequest + required bool + reply *diam.ProtocolErrorEvent + replyDelivery *protocolDelivery } type incoming struct { receivedAt time.Time @@ -31,42 +34,58 @@ type incoming struct { delivery *protocolDelivery } type session struct { - retired atomic.Bool - closing atomic.Bool // logical Closing, before DPR/DPA effects - terminal atomic.Bool // committed rejection or local close, before transport completion - retireErr error - acceptCleaned bool // guarded by Manager.mu - admitted atomic.Bool - retireOnce sync.Once - meta *smpeer.Metadata - m *Manager - requests *diam.PendingRequests - c diam.Conn - actor *actor - gen uint64 - inbound bool - firstMu sync.Mutex - first bool - rejectingFirst bool - preTimer Timer - writes chan writeRequest - beforeAdmission func() - afterAdmission func() - beforePublication func(*diam.Message) - ingressMu sync.Mutex - ingressSequence uint64 // guarded by ingressMu; successful admissions only - receivedSequence uint64 // actor-owned completion of admitted receive decisions - ingressClosed bool - ingress chan incoming - closed chan struct{} - closeOnce sync.Once - cerRequest *diam.Message - cerHop, cerEnd uint32 - localApps []uint32 // applications actually advertised on this connection + coreMu sync.Mutex + core *protocol.Core + coreID protocol.Connection + startup []protocol.Effect + coreEffects []protocol.Effect + coreEffectSpare []protocol.Effect + coreTimers [protocol.ReceiverDisconnectTimer + 1]Timer + coreWrites map[uint64]*diam.RequestWrite + coreHeaders map[uint64]diam.Header + coreTimeouts chan protocol.Input + candidate uint64 + retired atomic.Bool + closing atomic.Bool // logical Closing, before DPR/DPA effects + terminal atomic.Bool // committed rejection or local close, before transport completion + retireErr error + acceptCleaned bool // guarded by Manager.mu + admitted atomic.Bool + retireOnce sync.Once + meta *smpeer.Metadata + m *Manager + requests *diam.PendingRequests + c diam.Conn + actor *actor + gen uint64 + inbound bool + firstMu sync.Mutex + first bool + preTimer Timer + writes chan writeRequest + beforeAdmission func() + afterAdmission func() + beforePublication func(*diam.Message) + beforeInFlightScan func() // observes drain inspection in tests; nil in use + ingressMu sync.Mutex + ingressSequence uint64 // guarded by ingressMu; successful admissions only + receivedSequence uint64 // actor-owned completion of admitted receive decisions + ingressClosed bool + ingress chan incoming + closed chan struct{} + closeOnce sync.Once + cerRequest *diam.Message + cerHop, cerEnd uint32 + localApps []uint32 // applications actually advertised on this connection } func (m *Manager) newSession(c diam.Conn, a *actor, gen uint64, inbound bool) *session { s := &session{m: m, c: c, actor: a, gen: gen, inbound: inbound, writes: make(chan writeRequest, m.cfg.Limits.Events), ingress: make(chan incoming, m.cfg.Limits.Events), closed: make(chan struct{})} + if err := s.initProtocol(); err != nil { + m.reportLocal(s, nil, "peer: protocol initialization failed; closing connection", err) + c.Close() + return nil + } if tracker, ok := diam.ConnAs[interface{ PendingRequests() *diam.PendingRequests }](c); ok { s.requests = tracker.PendingRequests() } @@ -74,12 +93,15 @@ func (m *Manager) newSession(c diam.Conn, a *actor, gen uint64, inbound bool) *s m.mu.Lock() if m.closing || c.Closed() || a != nil && (a.retiring.Load() || !a.leaseLive()) { m.mu.Unlock() - c.Close() + s.close() return nil } m.sessions[c] = s m.wg.Add(3) m.mu.Unlock() + if inbound { + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) + } go s.writer() go s.watch() go s.dispatch() @@ -132,7 +154,7 @@ func (s *session) dispatch() { if a, _ := s.binding(); a != nil { a.post(event{kind: wireEvent, s: s, msg: in.msg, messageErr: in.err, receivedAt: in.receivedAt, receiveSequence: in.sequence}) } else { - _ = s.m.answerMessageError(s, in.msg, in.err) + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: in.msg, Error: in.err, ErrorInput: true, At: in.receivedAt, Sequence: in.sequence}, nil) } } else { s.m.processDIAMAt(s, in.msg, in.receivedAt, in.sequence) @@ -144,7 +166,7 @@ func (s *session) dispatch() { if a, _ := s.binding(); a != nil { a.commit(event{kind: wireEvent, s: s, msg: in.msg, messageErr: in.err, receivedAt: in.receivedAt, receiveSequence: in.sequence}, d) } else { - _ = s.m.answerMessageError(s, in.msg, in.err, d) + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: in.msg, Error: in.err, ErrorInput: true, At: in.receivedAt, Sequence: in.sequence}, d) } } else { s.m.processDIAMAt(s, in.msg, in.receivedAt, in.sequence, d) @@ -167,6 +189,8 @@ func (s *session) dispatch() { } case in := <-s.ingress: dispatch(in) + case in := <-s.coreTimeouts: + s.postProtocol(in) } } } @@ -226,25 +250,6 @@ func (s *session) sendWrite(w writeRequest) error { return errQueueFull } } -func (s *session) sendControl(msg *diam.Message) error { - if msg == nil || msg.Header == nil || msg.Header.CommandFlags&diam.RequestFlag == 0 { - return ErrInvalidRequest - } - m := s.m - m.pendingMu.Lock() - hop := m.nextHopIDLocked(s, 0, false) - msg.Header.HopByHopID = hop - if m.controls[s] == nil { - m.controls[s] = make(map[uint32]struct{}) - } - m.controls[s][hop] = struct{}{} - m.pendingMu.Unlock() - if err := s.send(msg, false); err != nil { - s.releaseControl(hop) - return err - } - return nil -} func (s *session) releaseControl(hop uint32) { s.m.pendingMu.Lock() delete(s.m.controls[s], hop) @@ -260,7 +265,18 @@ func (s *session) closeObserved() { s.closeWithFailover(false) } func (s *session) closeWithFailover(immediate bool) { + if immediate { + s.abortProtocol() + } s.closeOnce.Do(func() { + s.coreMu.Lock() + for i, timer := range s.coreTimers { + if timer != nil { + timer.Stop() + s.coreTimers[i] = nil + } + } + s.coreMu.Unlock() s.firstMu.Lock() if s.preTimer != nil { s.preTimer.Stop() @@ -284,12 +300,24 @@ func (s *session) closeWithFailover(immediate bool) { } func (s *session) writer() { defer s.m.wg.Done() + defer func() { + for { + select { + case w := <-s.writes: + if w.protocolToken != 0 { + s.finishProtocolWriter(w, protocol.SkippedStage, 0, diam.ErrConnectionRetired) + } + default: + return + } + } + }() for { select { case <-s.closed: return case w := <-s.writes: - if s.isRetired() || s.writeOne(w) { + if s.writeOne(w) { return } } @@ -299,6 +327,12 @@ func (s *session) writer() { // writeOne retains a selected request's identifier until the native writer has // returned, even if logical completion removed the Manager's pending entry. func (s *session) writeOne(w writeRequest) bool { + if w.protocolToken != 0 { + return s.writeProtocol(w) + } + if s.isRetired() { + return true + } if w.pending != nil { s.m.pendingMu.Lock() if w.pending.done || w.pending.current != s || s.isRetired() || s.closing.Load() { diff --git a/diam/peer/session_core_test.go b/diam/peer/session_core_test.go new file mode 100644 index 0000000..372cb5d --- /dev/null +++ b/diam/peer/session_core_test.go @@ -0,0 +1,91 @@ +package peer + +import ( + "errors" + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +func TestSessionCommitsInitiatorStartupBeforeRegistration(t *testing.T) { + m, err := New(Config{Settings: testSettings("local.example.net"), Clock: &fakeClock{}}) + if err != nil { + t.Fatal(err) + } + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net"}, events: make(chan event, 8), done: make(chan struct{})} + s := m.newSession(newFakeConn(), a, 1, false) + if s == nil { + t.Fatal("session construction failed") + } + if phase := s.protocolSnapshot().Phase(); phase != protocol.WaitCEA { + t.Fatalf("published initiator phase=%v, want WaitCEA", phase) + } + if len(s.writes) != 0 { + t.Fatal("CER published before dial completion") + } + s.close() + m.wg.Wait() + closeManager(t, m, nil) +} + +type heldCoreFailureConn struct { + *fakeConn + entered, release chan struct{} + err error +} + +func (c *heldCoreFailureConn) WriteStream(_ []byte, _ uint) (int, error) { + close(c.entered) + <-c.release + return 0, c.err +} + +func TestAttemptedRequiredReplyReportsFailureAfterRetirement(t *testing.T) { + a, s, _ := newReportingRawActor(t) + reportingOpen(t, s) + want := errors.New("required reply transport failure") + c := &heldCoreFailureConn{fakeConn: s.c.(*fakeConn), entered: make(chan struct{}), release: make(chan struct{}), err: want} + s.c = c + events := make(chan diam.ProtocolErrorEvent, 1) + a.m.cfg.Settings.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } + a.onWire(event{s: s, msg: watchdogRequest(t)}) + w := requiredQueuedWrite(t, s) + done := make(chan struct{}) + go func() { s.writeProtocol(w); close(done) }() + <-c.entered + s.retired.Store(true) + s.applyProtocol(protocol.Input{Kind: protocol.Retire}, nil) + close(c.release) + <-done + select { + case e := <-events: + if e.Err.Kind() != diam.ProtocolReplyFailure || e.Reply != diam.ProtocolReplyFailed || e.ReplyCode != diam.Success { + t.Fatalf("attempted reply failure=%+v", e) + } + case <-time.After(time.Second): + t.Fatal("retirement erased an attempted required reply's failure") + } +} + +func TestFailedCoreQueueDisposesWriterWithoutEventCapacity(t *testing.T) { + a, s, _ := newReportingRawActor(t) + reportingOpen(t, s) + a.events = make(chan event, 1) + a.events <- event{} + for len(s.writes) < cap(s.writes) { + s.writes <- writeRequest{} + } + done := make(chan struct{}) + defer func() { close(a.done); <-done }() + go func() { a.onWire(event{s: s, msg: watchdogRequest(t)}); close(done) }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("queue rejection waited for ordinary event capacity to dispose its writer") + } + if len(s.protocolSnapshot().Controls()) != 0 { + t.Fatal("failed queue retained a control writer") + } +} diff --git a/diam/peer/session_order_test.go b/diam/peer/session_order_test.go index aa998b9..0cc21a1 100644 --- a/diam/peer/session_order_test.go +++ b/diam/peer/session_order_test.go @@ -19,6 +19,9 @@ func TestReadAnswerPrecedesConnectionGone(t *testing.T) { c := newFakeConn() s := &session{m: m, c: c, actor: a, gen: 1, writes: make(chan writeRequest, 8), ingress: make(chan incoming, 8), closed: make(chan struct{})} a.active, a.i = s, s + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } a.publish(nil) m.peers[identity(a.cfg.Host)] = a m.sessions[c] = s diff --git a/diam/peer/session_protocol.go b/diam/peer/session_protocol.go new file mode 100644 index 0000000..143964e --- /dev/null +++ b/diam/peer/session_protocol.go @@ -0,0 +1,826 @@ +package peer + +import ( + "errors" + "fmt" + "io" + "log/slog" + "sync/atomic" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/parserbridge" + "github.com/gomaja/go-diameter/diam/internal/protocol" + "github.com/gomaja/go-diameter/diam/internal/watchdog" + "github.com/gomaja/go-diameter/diam/sm/smparser" + "github.com/gomaja/go-diameter/diam/sm/smpeer" +) + +var protocolInstance atomic.Uint64 + +var validatedCER = parserbridge.FromValidatedCER.(func(*base.CER, []uint32) *smparser.CER) +var validatedCEA = parserbridge.FromValidatedCEA.(func(*base.CEA, []uint32, error) (*smparser.CEA, error)) + +func protocolMetadata(snapshot protocol.Snapshot) *smpeer.Metadata { + caps := snapshot.Capabilities() + if caps.CER != nil { + return smpeer.FromCER(validatedCER(caps.CER, caps.ParsedApplications)) + } + parsed, _ := validatedCEA(caps.CEA, caps.ParsedApplications, nil) + return smpeer.FromCEA(parsed) +} + +// Startup is committed before session registration or worker publication. +// Its effects wait for the actor's dial completion on an initiator leg. +func (s *session) initProtocol() error { + local := s.m.baseSettings(s.c) + local.ResolveHostIPAddresses = nil + role := protocol.Initiator + if s.inbound { + role = protocol.Responder + } + s.coreID = protocol.Connection{Instance: protocolInstance.Add(1), Generation: s.gen} + expected := "" + recovery := false + if s.actor != nil { + expected = string(s.actor.cfg.Host) + if value := s.actor.snapshotValue.Load(); value != nil { + recovery = value.(PeerSnapshot).Watchdog == WatchdogDown + } + } + core, err := protocol.New(protocol.Config{Connection: s.coreID, Role: role, Local: local, + Dictionary: s.m.dictionary(), ExpectedHost: expected, TLS: s.c.TLS() != nil, QueueWrites: true, + HandshakeTimeout: s.m.cfg.Timers.CER, ClosingTimeout: s.m.cfg.Timers.Closing, + Watchdog: true, WatchdogInterval: max(s.m.cfg.Timers.TwInit, watchdog.MinimumTw), Recovery: recovery, + CandidateDeadlineOwnedByPeer: true, + IgnoreBoundHeaderlessInput: true, + InitialNonCERHeaderOnError: true, + RejectFatalRepeatedCER: true, + ValidateCompletedCEA: true, + PreserveDecoderFatalityOnHeaderError: true, + ReaderOwnsMessageErrorClosure: true, + RetainClosingErrorFatality: true, + SeparateHeaderErrorDiagnostics: true, + CloseCEROnInvalidHeaderBitsOnly: true, + FatalBodyErrorKeepsWatchdogDeadline: true, + FatalCEAErrorBeforeCorrelation: true, + DWAOwnershipBeforeSemantics: true, + DWAIdentityBeforeResult: true, + PreserveReplyErrorFormatWrapper: true, + AllowAnyLocalDisconnectCause: true, + ValidateRequests: s.m.cfg.Settings.ValidateRequests, RejectUnknownMandatoryAVPs: s.m.cfg.Settings.RejectUnknownMandatoryAVPs}) + if err != nil { + return err + } + s.core = core + s.coreTimeouts = make(chan protocol.Input, 1) + s.coreWrites = make(map[uint64]*diam.RequestWrite) + s.coreHeaders = make(map[uint64]diam.Header) + s.localApps = base.AdvertisedApplicationIDs(local) + s.startup = core.Handle(protocol.Input{Kind: protocol.Start, Connection: s.coreID, At: s.m.now(), DeferCER: !s.inbound}) + return nil +} + +func (s *session) takeStartup() []protocol.Effect { + s.coreMu.Lock() + es := s.startup + if !s.inbound { + es = append(es, s.core.Handle(protocol.Input{Kind: protocol.BeginCER, Connection: s.coreID, At: s.m.now()})...) + } + es = s.prepareProtocol(es) + s.startup = nil + s.coreMu.Unlock() + return es +} + +func (s *session) protocolSnapshot() protocol.Snapshot { + s.coreMu.Lock() + defer s.coreMu.Unlock() + return s.core.Snapshot() +} + +func (s *session) postProtocol(in protocol.Input, prior ...*protocolDelivery) { + var previous *protocolDelivery + if len(prior) != 0 { + previous = prior[0] + } + if a, gen := s.binding(); a != nil { + a.post(event{kind: coreEvent, s: s, gen: gen, input: in, replyDelivery: previous}) + // Closure may cross the phase check before this actor enqueue. Tokens + // make terminal writer disposal idempotent if the actor also drains it. + if in.Kind == protocol.WriteResult { + s.terminalProtocol(in) + } + } else { + d := newProtocolDelivery() + d.priorReply = previous + s.applyProtocol(in, d) + d.finish() + d.deliver(s.m) + } +} + +func (s *session) applyProtocol(in protocol.Input, d *protocolDelivery) { + in.Connection = s.coreID + if in.At.IsZero() { + in.At = s.m.now() + } + if a, _ := s.binding(); a != nil { + in.Interval = a.watchdogInterval() + } + if in.Kind == protocol.Receive { + in.Unhandled = true + if s.m.matchesPendingAnswer(s, in.Message) { + in.Ownership = 1 + } + } + s.coreMu.Lock() + // The first DPR obtains its drain snapshot through Frozen. Only later + // Closing inputs can consume another snapshot; Open traffic never scans + // Manager's pending request set merely to report protocol activity. + if in.Kind == protocol.Receive && s.core.Phase() == protocol.Closing { + in.InFlight = s.protocolInFlight() + } + es := s.prepareProtocol(s.handleProtocolLocked(in)) + s.coreMu.Unlock() + if in.Kind == protocol.ReleaseAdmission { + if a, _ := s.binding(); a != nil { + a.syncProtocol(s, in) + } + } + s.runProtocol(in, es, d) +} + +// handleProtocolLocked lends one batch its storage until runProtocol returns. +// Nested decisions and concurrent completions obtain independent buffers. +func (s *session) handleProtocolLocked(in protocol.Input) []protocol.Effect { + buffer := s.coreEffects + s.coreEffects = s.coreEffectSpare + s.coreEffectSpare = nil + return s.core.HandleInto(in, buffer[:0]) +} + +func (s *session) releaseProtocolEffects(es []protocol.Effect) { + clear(es[:cap(es)]) + s.coreMu.Lock() + if cap(es) > cap(s.coreEffects) { + s.coreEffects, es = es[:0], s.coreEffects + } + if cap(es) > cap(s.coreEffectSpare) { + s.coreEffectSpare = es[:0] + } + s.coreMu.Unlock() +} + +// Bounded ownership and timer bookkeeping commits with the core decision. +// Callbacks and transport writes execute after coreMu is released. +func (s *session) prepareProtocol(es []protocol.Effect) []protocol.Effect { + out := es[:0] + for _, e := range es { + switch e.Kind { + case protocol.FreezeAdmission: + s.m.closeAdmission(s) + inFlight := s.protocolInFlight() + out = append(out[:len(out):len(out)], s.prepareProtocol(s.core.Handle(protocol.Input{Kind: protocol.Frozen, Connection: s.coreID, Token: e.Token, InFlight: inFlight}))...) + case protocol.ArmTimer: + if old := s.coreTimers[e.Timer]; old != nil { + old.Stop() + } + kind, token, deadline := e.Timer, e.Token, e.Deadline + s.coreTimers[kind] = s.m.cfg.Clock.AfterFunc(max(0, deadline.Sub(s.m.now())), func() { + in := protocol.Input{Kind: protocol.TimerExpired, Timer: kind, Token: token, At: maxTime(deadline, s.m.now()), AfterReceive: s.receiveBarrier()} + if a, _ := s.binding(); a != nil { + s.postProtocol(in) + } else { + select { + case s.coreTimeouts <- in: + case <-s.closed: + } + } + }) + case protocol.StopTimer: + if old := s.coreTimers[e.Timer]; old != nil { + old.Stop() + s.coreTimers[e.Timer] = nil + } + case protocol.CancelSend: + s.coreWrites[e.Token].Cancel() + case protocol.CompleteRequest: + h := e.Notification.Message.Header + for token, header := range s.coreHeaders { + if header.CommandCode == h.CommandCode && header.HopByHopID == h.HopByHopID && header.EndToEndID == h.EndToEndID { + s.coreWrites[token].Cancel() + } + } + out = append(out, e) + default: + out = append(out, e) + } + } + return out +} + +// Native publication determines whether the outstanding watchdog was sent. +// A local DPR is disconnect control, outside the application drain set. +func (s *session) protocolInFlight() bool { + if s.beforeInFlightScan != nil { + s.beforeInFlightScan() + } + if s.m.hasInFlightRequests(s) { + return true + } + for _, control := range s.core.Progress().Controls { + if control.Command != diam.DeviceWatchdog || !control.Logical || control.Answered { + continue + } + if s.requests == nil { + return true + } + return s.requests.MatchAnswer(&diam.Message{Header: &diam.Header{CommandCode: diam.DeviceWatchdog, HopByHopID: control.Reservation.Hop, EndToEndID: control.Reservation.End}}, false) + } + return false +} + +func maxTime(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func (s *session) runProtocol(in protocol.Input, es []protocol.Effect, d *protocolDelivery) { + defer s.releaseProtocolEffects(es) + if d == nil { + d = newProtocolDelivery() + defer func() { d.finish(); d.deliver(s.m) }() + } + a, _ := s.binding() + // Diagnostics precede transport closure and observer publication. + for _, e := range es { + if e.Kind == protocol.Notify && e.Notification.Kind == protocol.HandshakeNotice && in.Kind == protocol.ReserveResult && e.Notification.Handshake.Err() != nil && a != nil && a.i == s { + a.failLocal("build CER", e.Notification.Handshake.Err()) + } + if e.Kind == protocol.Notify && in.Kind != protocol.WriteResult { + s.recordProtocol(in, e.Notification) + } + } + for _, e := range es { + switch e.Kind { + case protocol.ReserveIDs: + s.m.pendingMu.Lock() + hop := s.m.nextHopIDLocked(s, 0, false) + if s.m.controls[s] == nil { + s.m.controls[s] = make(map[uint32]struct{}) + } + s.m.controls[s][hop] = struct{}{} + s.m.pendingMu.Unlock() + end := s.m.endToEnd.allocate() + s.applyProtocol(protocol.Input{Kind: protocol.ReserveResult, Token: e.Token, Reservation: protocol.Reservation{Token: e.Token, Hop: hop, End: end}}, d) + case protocol.ReleaseIDs: + s.releaseControl(e.Reservation.Hop) + case protocol.Send: + s.sendProtocol(e, in, d) + case protocol.ValidatedCER: + s.candidate = e.Token + s.cerRequest = in.Message + s.meta = protocolMetadata(*e.Snapshot) + if a == nil { + s.m.mu.RLock() + a = s.m.peers[identity(s.meta.OriginHost)] + s.m.mu.RUnlock() + if a == nil { + s.m.report(s, in.Message, fmt.Errorf("peer: unknown Origin-Host %s", s.meta.OriginHost)) + s.applyProtocol(protocol.Input{Kind: protocol.RejectCandidate, Token: e.Token, ResultCode: diam.UnknownPeer, Message: in.Message}, d) + continue + } + if !s.bind(a, 0) { + continue + } + a.commit(event{kind: rConnCER, s: s, msg: in.Message, meta: s.meta.Clone(), receivedAt: in.At, receiveSequence: in.Sequence}, d) + return + } else { + a.onCER(event{s: s, msg: in.Message, meta: s.meta.Clone()}) + } + case protocol.Publish: + if s.isRetired() { + continue + } + s.meta = protocolMetadata(*e.Snapshot) + if a != nil { + a.meta = s.meta.Clone() + } + case protocol.HandshakeReady: + if a == nil || s.isRetired() { + continue + } + if !s.inbound { + if err := a.admitDiscovery(s, s.meta); err != nil { + a.fail(err) + continue + } + a.openInitiator(s) + s.applyProtocol(protocol.Input{Kind: protocol.ReleaseAdmission, Token: e.Token}, d) + if a.state == IOpen { + a.everOpen = true + a.publish(nil) + } + } else { + s.applyProtocol(protocol.Input{Kind: protocol.ReleaseAdmission, Token: e.Token}, d) + } + case protocol.Admit: + if s.isRetired() { + continue + } + if e.Ownership != 0 { + s.m.receiveClosingAnswer(s, e.Message) + } else { + s.m.receiveAnswer(s, e.Message, d) + } + case protocol.CompleteRequest: + if s.requests != nil { + s.requests.MatchAnswer(&diam.Message{Header: &e.Notification.Message.Header}, true) + } + case protocol.PeerDisconnectDecision: + if a != nil && a.active == s { + a.suppressReconnect = e.Cause == 1 || e.Cause == 2 + } + case protocol.Close: + if a != nil && a.active == s { + for _, notice := range es { + if notice.Kind == protocol.Notify && notice.Notification.Kind == protocol.DisconnectNotice && notice.Notification.Outcome == protocol.Completed { + if s == a.i { + a.step(iDPA, s, in.Message) + } else { + a.step(rDPA, s, in.Message) + } + break + } + } + } + s.close() + case protocol.Notify: + s.notifyProtocol(in, e.Notification, d) + } + } + if a != nil && (a.i == s || a.r == s || a.active == s) { + a.syncProtocol(s, in) + } +} + +func (s *session) sendProtocol(e protocol.Effect, in protocol.Input, d *protocolDelivery) { + m := e.Message + if e.Notification.Protocol != "" { + s.recordProtocol(in, e.Notification) + } + var ticket *diam.RequestWrite + if m.Header.CommandFlags&diam.RequestFlag != 0 && s.requests != nil { + ticket = s.requests.PrepareWrite(m.Header) + } + s.coreMu.Lock() + s.coreWrites[e.Token] = ticket + s.coreHeaders[e.Token] = *m.Header + s.coreMu.Unlock() + w := writeRequest{msg: m, required: e.Required, closeAfter: e.CloseAfter, protocolToken: e.Token, protocolSource: in, replyDelivery: d} + if e.Required && s.m.ProtocolErrorEnabled() { + w.reply = requiredReplyEvent(s.c, in.Message, answerCode(m)) + } + err := s.sendWrite(w) + if err != nil { + if a, _ := s.binding(); a != nil && (a.i == s || a.r == s || a.active == s) && e.Notification.Protocol == "" { + // Handle commits the failed queue decision before the peer-level + // failure closes its other election leg. + s.coreMu.Lock() + es := s.prepareProtocol(s.core.Handle(protocol.Input{Kind: protocol.WriteResult, Connection: s.coreID, Token: e.Token, Stage: protocol.QueueStage, ReplyCode: answerCode(m), Err: err})) + s.coreMu.Unlock() + a.failLocal("send "+controlName(m), err) + s.runProtocol(protocol.Input{Kind: protocol.WriteResult, Stage: protocol.QueueStage, Err: err, Message: in.Message}, es, d) + s.finishProtocolWriter(w, protocol.SkippedStage, 0, err) + return + } + text := "peer: request rejection answer failed" + if in.Error != nil { + text = "peer: error answer failed; closing connection" + } + if e.Notification.Protocol == diam.ProtocolCERRejected { + text = "peer: send rejected CER answer failed; closing connection" + } + if e.Notification.Protocol == diam.ProtocolUnsupportedCommand || e.Notification.Protocol == diam.ProtocolUnsupportedApplication { + text = "peer: unsupported request answer failed" + } + s.m.reportLocal(s, in.Message, text, err) + } + s.applyProtocol(protocol.Input{Kind: protocol.WriteResult, Token: e.Token, Stage: protocol.QueueStage, ReplyCode: answerCode(m), Err: err, Message: in.Message, Error: in.Error}, d) + if err != nil { + s.finishProtocolWriter(w, protocol.SkippedStage, 0, err) + } +} + +func answerCode(m *diam.Message) uint32 { + if m.Header.CommandFlags&diam.RequestFlag != 0 { + return 0 + } + if field, err := m.FindAVP(avp.ResultCode, 0); err == nil { + if value, ok := field.Data.(datatype.Unsigned32); ok { + return uint32(value) + } + } + return 0 +} +func controlName(m *diam.Message) string { + if m == nil || m.Header == nil { + return "reply" + } + req := m.Header.CommandFlags&diam.RequestFlag != 0 + switch m.Header.CommandCode { + case diam.CapabilitiesExchange: + if req { + return "CER" + } + return "CEA" + case diam.DeviceWatchdog: + if req { + return "DWR" + } + return "DWA" + case diam.DisconnectPeer: + if req { + return "DPR" + } + return "DPA" + } + return "reply" +} + +func (s *session) finishProtocolWriter(w writeRequest, stage protocol.WriteStage, n int64, err error) { + in := protocol.Input{Kind: protocol.WriteResult, Connection: s.coreID, Token: w.protocolToken, Stage: stage, Bytes: n, ExpectedBytes: int64(w.msg.Len()), ReplyCode: answerCode(w.msg), Err: err, Message: w.protocolSource.Message, Error: w.protocolSource.Error} + s.coreMu.Lock() + ticket := s.coreWrites[w.protocolToken] + delete(s.coreWrites, w.protocolToken) + delete(s.coreHeaders, w.protocolToken) + completed := s.core.CompleteQueuedReply(in) + s.coreMu.Unlock() + ticket.Release() + if completed { + return + } + if s.terminalProtocol(in) { + return + } + s.postProtocol(in, w.replyDelivery) +} + +func (s *session) writeProtocol(w writeRequest) bool { + stage := protocol.WrittenStage + var n int64 + var err error + if s.beforePublication != nil { + s.beforePublication(w.msg) + } + probe := w.msg.Header.CommandCode == diam.DeviceWatchdog && w.msg.Header.CommandFlags&diam.RequestFlag != 0 + if s.isRetired() || s.c.Closed() || probe && s.closing.Load() || !s.leaseAllowsWrite(w) { + stage, err = protocol.SkippedStage, diam.ErrConnectionRetired + } else { + n, err = w.msg.WriteTo(s.c) + if n == 0 && (errors.Is(err, diam.ErrConnectionClosing) || errors.Is(err, diam.ErrConnectionRetired) || errors.Is(err, diam.ErrRequestCompleted)) { + stage = protocol.SkippedStage + } + if err == nil && n != int64(w.msg.Len()) { + err = io.ErrShortWrite + } + } + if err != nil && stage != protocol.SkippedStage { + s.m.reportLocal(s, w.msg, "peer: write failed; closing connection", err) + } + // Queue feedback commits before a concurrent writer reports completion. + // Reporting can remain blocked independently of timers and retirement. + if w.replyDelivery != nil { + <-w.replyDelivery.done + } + s.finishProtocolWriter(w, stage, n, err) + if err != nil && stage == protocol.WrittenStage && w.reply != nil { + w.replyDelivery.replyFailed(s.m, w.reply) + } + if err != nil && stage != protocol.SkippedStage || w.closeAfter && err == nil { + s.close() + return true + } + return false +} + +func (a *actor) syncProtocol(s *session, in protocol.Input) { + if a.active != s { + return + } + s.coreMu.Lock() + progress := s.core.Progress() + timer := s.coreTimers[protocol.WatchdogTimer] + s.coreMu.Unlock() + wd := progress.Watchdog + a.wdTimer = timer + a.wdToken = progress.Timers[protocol.WatchdogTimer].Token + a.pendingWatchdog, a.numDWA = wd.Pending, wd.NumDWA + for _, control := range progress.Controls { + switch control.Command { + case diam.CapabilitiesExchange: + s.cerHop, s.cerEnd = control.Reservation.Hop, control.Reservation.End + case diam.DeviceWatchdog: + a.watchdogHop, a.watchdogEnd, a.watchdogGen = control.Reservation.Hop, control.Reservation.End, s.gen + a.watchdogAnswered = control.Answered + case diam.DisconnectPeer: + a.pendingDPR, a.pendingDPREnd, a.pendingDPRGen, a.pendingDPRCommand = control.Reservation.Hop, control.Reservation.End, s.gen, control.Command + } + } + if progress.Phase == protocol.Closing && a.state != Closing { + a.stopTimer() + a.state = Closing + a.publish(nil) + } + if progress.Phase >= protocol.Closed { + return + } + if progress.Ready && s.inbound { + s.admitted.Store(true) + } + if a.watchdog != WatchdogState(wd.State) { + reason := "peer: traffic restored watchdog health" + if !a.everOpen { + reason = "peer: initial connection established" + } + if wd.State == watchdog.Suspect { + reason = "peer: unanswered DWR" + } + if wd.State == watchdog.Reopen { + reason = "peer: recovered connection awaiting three DWA" + } + if wd.State == watchdog.Okay && a.watchdog == WatchdogReopen { + reason = "peer: three valid recovery DWA" + } + if in.Kind == protocol.Receive && in.Message != nil && in.Message.Header.CommandCode == diam.DeviceWatchdog && in.Message.Header.CommandFlags&diam.RequestFlag == 0 && a.watchdog != WatchdogReopen { + reason = "peer: valid DWA restored watchdog health" + } + a.changeWatchdog(WatchdogState(wd.State), errors.New(reason)) + if wd.State == watchdog.Okay { + a.failures = 0 + } + } +} + +func (s *session) recordProtocol(in protocol.Input, n protocol.Notification) { + if n.Protocol == "" { + return + } + a, _ := s.binding() + msg := in.Message + if msg == nil && n.Message.HasHeader { + msg = &diam.Message{Header: &n.Message.Header} + } + err := n.CauseError + if err == nil { + err = diam.NewProtocolError(n.Protocol, n.ResultCode) + } + if n.ReplyError != nil { + text := "peer: request rejection answer failed" + switch { + case in.ErrorInput: + text = "peer: error answer failed; closing connection" + case n.Protocol == diam.ProtocolCERRejected: + text = "peer: build rejected CER answer failed; closing connection" + case n.Diagnostic == "unhandled": + if msg != nil { + s.m.report(s, msg, fmt.Errorf("peer: unhandled message %d/%d", msg.Header.ApplicationID, msg.Header.CommandCode)) + } + text = "peer: unsupported request answer failed" + default: + if n.CauseError != nil { + s.m.report(s, msg, n.CauseError) + } + } + s.m.reportLocal(s, msg, text, n.ReplyError) + return + } + if n.Protocol == diam.ProtocolReplyFailure && n.CauseError != nil && in.Kind == protocol.Activate && a != nil { + a.failLocal("build CEA", n.CauseError) + return + } + if n.Protocol == diam.ProtocolHandshakeTimeout && a == nil { + err = errors.New("peer: pre-CER timeout") + } + if n.Protocol == diam.ProtocolHandshakeState && (a == nil || a.i != s) { + if a == nil { + if in.ErrorInput { + err = fmt.Errorf("peer: malformed pre-CER message; closing connection: %w", err) + } else { + err = errors.New("peer: first inbound message is not CER") + } + } else if !in.ErrorInput { + text := "peer: message before handshake" + if a.state == WaitICEA || a.state == WaitReturns { + text = "peer: message before answering connection handshake" + } + err = errors.New(text) + } + s.m.report(s, msg, err) + return + } + if n.Diagnostic == "header-error" || n.Diagnostic == "reject-cer-header" { + s.m.report(s, msg, err) + if n.Diagnostic == "header-error" { + return + } + } + if n.Diagnostic == "external-reject-cer" || n.Diagnostic == "reject-cer-header" { + s.m.report(s, msg, fmt.Errorf("peer: invalid CER rejected; closing connection: %w", err)) + return + } + if n.Diagnostic == "external-message-error" { + if n.Reply == diam.ProtocolReplyFailed && n.CauseError != nil { + s.m.reportLocal(s, msg, "peer: error answer failed; closing connection", err) + } + return + } + if n.Diagnostic == "unhandled" && msg != nil { + s.m.report(s, msg, fmt.Errorf("peer: unhandled message %d/%d", msg.Header.ApplicationID, msg.Header.CommandCode)) + return + } + if n.Protocol == diam.ProtocolCERRejected && n.CauseError == nil { + return + } + if n.CauseError == nil && (n.Protocol == diam.ProtocolInvalidRequest || n.Protocol == diam.ProtocolUnsupportedAVP || n.Protocol == diam.ProtocolUnsupportedApplication) { + return + } + if n.Protocol == diam.ProtocolMalformedMessage && in.ErrorInput && n.Diagnostic != "reject-cer" { + return + } + if n.Diagnostic == "reject-cer" && in.ErrorInput && in.Error != nil { + s.m.report(s, msg, fmt.Errorf("peer: malformed CER rejected; closing connection: %w", err)) + return + } + pendingCEAHeader := n.Protocol == diam.ProtocolInvalidHeader && a != nil && a.i == s && (a.state == WaitICEA || a.state == WaitReturns) + if (n.Protocol == diam.ProtocolCEARejected || pendingCEAHeader) && a != nil && a.i == s { + semantic := s.protocolSnapshot().Handshake() + if semantic.Err() != nil { + _, err = validatedCEA(semantic.CEA(), nil, semantic.Err()) + } + } + if n.Protocol == diam.ProtocolRejectedDWA { + err = &dwaResultCodeError{result: n.ResultCode} + } + if n.Protocol == diam.ProtocolPeerRejected && msg != nil && msg.Header.CommandCode == diam.DisconnectPeer { + err = fmt.Errorf("peer: DPA Result-Code %d", n.ResultCode) + } + if n.Protocol == diam.ProtocolUnexpectedMessage && msg != nil && msg.Header.CommandFlags&diam.RequestFlag == 0 { + switch msg.Header.CommandCode { + case diam.CapabilitiesExchange: + if a != nil && a.i == s && (a.state == WaitICEA || a.state == WaitReturns) { + err = errors.New("peer: CEA identifiers mismatch") + } else { + err = s.unmatchedProtocolAnswer(msg) + } + case diam.DeviceWatchdog: + err = errors.New("peer: unsolicited or mismatched DWA") + case diam.DisconnectPeer: + switch n.Diagnostic { + case "dpa-host": + err = errors.New("peer: DPA Origin-Host mismatch") + default: + if s.closing.Load() { + err = errors.New("peer: DPA identifiers mismatch") + } else { + err = s.unmatchedProtocolAnswer(msg) + } + } + } + } + switch err.Error() { + case "DWR Origin-Host does not match the admitted peer (RFC 6733 §6.3)": + err = errors.New("peer: DWR Origin-Host mismatch") + case "DPR Origin-Host does not match the admitted peer (RFC 6733 §6.3)": + err = errors.New("peer: DPR Origin-Host mismatch") + case "DWA Origin-Host mismatch": + err = errors.New("peer: DWA Origin-Host mismatch") + case "DWA Origin-Realm mismatch": + err = errors.New("peer: DWA Origin-Realm mismatch") + } + if a != nil && (a.i == s || a.active == s) && n.Closing && (n.Protocol == diam.ProtocolCEARejected || pendingCEAHeader || n.Protocol == diam.ProtocolHandshakeTimeout || n.Protocol == diam.ProtocolHandshakeState || n.Protocol == diam.ProtocolWatchdogTimeout || n.Protocol == diam.ProtocolDisconnectTimeout) { + switch n.Protocol { + case diam.ProtocolCEARejected, diam.ProtocolInvalidHeader: + if in.ErrorInput { + err = errors.New("peer: non-CEA before handshake") + } else if errors.Is(err, protocol.ErrUnexpectedPeer) { + err = errors.New("peer: CEA Origin-Host mismatch") + } else { + err = fmt.Errorf("peer: invalid CEA: %w", err) + } + case diam.ProtocolHandshakeTimeout, diam.ProtocolDisconnectTimeout: + err = errors.New("peer: state timeout") + case diam.ProtocolHandshakeState: + err = errors.New("peer: non-CEA before handshake") + case diam.ProtocolWatchdogTimeout: + err = errors.New("peer: watchdog suspect timeout") + if a.watchdog == WatchdogReopen { + err = errors.New("peer: recovery DWR unanswered") + } + } + a.fail(err) + } else { + if n.Protocol == diam.ProtocolUnexpectedMessage && msg != nil && msg.Header.CommandCode == diam.CapabilitiesExchange && msg.Header.CommandFlags&diam.RequestFlag != 0 { + err = errors.New("peer: ignoring CER on admitted connection; accepted CER renewal is disabled or ineligible") + s.m.reportFailure(s, msg, slog.LevelWarn, err.Error(), err) + } else { + s.m.report(s, msg, err) + } + } +} + +// Application ownership remains with Manager; the core has already discarded +// this control answer. Preserve the shared reservation space's diagnostic. +func (s *session) unmatchedProtocolAnswer(msg *diam.Message) error { + s.m.pendingMu.Lock() + p := s.m.pending[s][msg.Header.HopByHopID] + unmatched := p == nil || p.done + s.m.pendingMu.Unlock() + if unmatched { + return errors.New("peer: unmatched answer (RFC 6733 §6.2.1)") + } + return errors.New("peer: unmatched or mismatched answer (RFC 6733 §6.2.1)") +} + +func (s *session) notifyProtocol(in protocol.Input, n protocol.Notification, d *protocolDelivery) { + if n.Protocol == "" { + return + } + if in.Kind == protocol.WriteResult && in.Stage == protocol.WrittenStage && in.Err != nil && n.Protocol == diam.ProtocolReplyFailure { + return + } + msg := in.Message + if msg == nil && n.Message.HasHeader { + msg = &diam.Message{Header: &n.Message.Header} + } + kind := n.Protocol + if in.ErrorInput && kind == diam.ProtocolCEARejected { + kind = diam.ProtocolHandshakeState + } + if kind == diam.ProtocolUnexpectedMessage && msg != nil && msg.Header.CommandCode == diam.DeviceWatchdog { + kind = diam.ProtocolInvalidDWA + } + if kind == diam.ProtocolUnexpectedMessage && msg != nil && msg.Header.CommandCode == diam.DisconnectPeer { + if s.closing.Load() { + kind = diam.ProtocolInvalidDPA + } + } + if s.m.ProtocolErrorEnabled() { + d.notifications = append(d.notifications, diam.ProtocolErrorEvent{Conn: s.c, Err: diam.NewProtocolError(kind, n.ResultCode), Message: n.Message, Closing: n.Closing, Reply: n.Reply, ReplyCode: n.ReplyCode}) + } +} + +// abortProtocol settles local shutdown before workers observe s.closed. Abort's +// Close effect is fulfilled by closeWithFailover after releasing coreMu; its +// handshake/disconnect notices are internal completion obligations. The existing +// executor commits timer stops and native cancellation under the protocol lock. +func (s *session) abortProtocol() { + s.coreMu.Lock() + if s.core == nil { + s.coreMu.Unlock() + return + } + if s.requests != nil { + s.requests.CloseAdmission() + for _, control := range s.core.Progress().Controls { + if control.Command != 0 { + s.requests.MatchAnswer(&diam.Message{Header: &diam.Header{CommandCode: control.Command, HopByHopID: control.Reservation.Hop, EndToEndID: control.Reservation.End}}, true) + } + } + } + es := s.prepareProtocol(s.core.Handle(protocol.Input{Kind: protocol.Abort, Connection: s.coreID, At: s.m.now()})) + s.coreMu.Unlock() + for _, e := range es { + if e.Kind == protocol.ReleaseIDs { + s.releaseControl(e.Reservation.Hop) + } + } + s.releaseProtocolEffects(es) +} + +// terminalProtocol preserves selected-writer disposal after actor shutdown. A +// terminal core can only release these control IDs; it cannot publish callbacks +// or revive peer state. Repeated completion for the same token is harmless. +func (s *session) terminalProtocol(in protocol.Input) bool { + s.coreMu.Lock() + if s.core == nil || s.core.Phase() < protocol.Closed { + s.coreMu.Unlock() + return false + } + in.Connection = s.coreID + es := s.handleProtocolLocked(in) + s.coreMu.Unlock() + for _, e := range es { + if e.Kind == protocol.ReleaseIDs { + s.releaseControl(e.Reservation.Hop) + } + } + s.releaseProtocolEffects(es) + return true +} diff --git a/diam/peer/shutdown_core_obligation_test.go b/diam/peer/shutdown_core_obligation_test.go new file mode 100644 index 0000000..b7d2ecf --- /dev/null +++ b/diam/peer/shutdown_core_obligation_test.go @@ -0,0 +1,74 @@ +package peer + +import ( + "testing" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +// A CER selected by a session not yet attached to either actor leg still owns +// its handshake, native publication and core writer completion during shutdown. +func TestActorShutdownSettlesUnattachedCoreCompletion(t *testing.T) { + for _, observed := range []bool{false, true} { + for _, late := range []bool{false, true} { + t.Run(map[bool]string{false: "local close/", true: "observed close/"}[observed]+map[bool]string{false: "queued completion", true: "completion after actor exit"}[late], func(t *testing.T) { + m, err := New(Config{Settings: testSettings("local.example.net"), Clock: &fakeClock{}, Timers: Timers{Closing: time.Second}}) + if err != nil { + t.Fatal(err) + } + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: Closed, events: make(chan event, 8), done: make(chan struct{})} + a.publish(nil) + s := &session{m: m, c: newFakeConn(), actor: a, gen: 11, writes: make(chan writeRequest, 8), closed: make(chan struct{}), requests: new(diam.PendingRequests)} + m.sessions[s.c] = s + renewalCoreSession(t, s, false, false, "example.net") + s.runProtocol(protocol.Input{Kind: protocol.Start}, s.takeStartup(), nil) + w := renewalCoreWrite(t, s, diam.CapabilitiesExchange) + wire, err := w.msg.Serialize() + if err != nil { + t.Fatal(err) + } + nativeDone := s.requests.BeginWrite(wire) + nativeDone(true) + if !s.requests.HasPending() { + t.Fatal("fixture CER did not acquire native answer ownership") + } + if !late { + s.finishProtocolWriter(w, protocol.WrittenStage, int64(len(wire)), nil) + } + if observed { + s.closeObserved() + if s.protocolSnapshot().Phase() != protocol.WaitCEA { + t.Fatal("observed close prematurely terminalized core before decoded-message drain") + } + } + a.closeOwnedSessions() + close(a.done) + if got := s.protocolSnapshot().Phase(); got != protocol.Closed { + t.Errorf("shutdown left core phase %v", got) + } + if s.requests.HasPending() { + t.Error("shutdown retained native CER answer ownership") + } + if late { + s.finishProtocolWriter(w, protocol.WrittenStage, int64(len(wire)), nil) + } + snapshot := s.protocolSnapshot() + if controls := snapshot.Controls(); len(controls) != 0 { + t.Errorf("shutdown lost writer completion: controls=%+v", controls) + } + s.coreMu.Lock() + pendingWrites := len(s.coreWrites) + s.coreMu.Unlock() + if pendingWrites != 0 { + t.Errorf("shutdown retained %d selected native writer tickets", pendingWrites) + } + if timer := snapshot.Timer(protocol.HandshakeTimer); timer.Token != 0 { + t.Errorf("shutdown left handshake timer armed: %+v", timer) + } + closeManager(t, m, nil) + }) + } + } +} diff --git a/diam/peer/validation.go b/diam/peer/validation.go deleted file mode 100644 index bd3bb55..0000000 --- a/diam/peer/validation.go +++ /dev/null @@ -1,49 +0,0 @@ -package peer - -import ( - "github.com/gomaja/go-diameter/diam" - "github.com/gomaja/go-diameter/diam/internal/base" -) - -func (m *Manager) rejectBaseMessage(s *session, msg *diam.Message, err *diam.MessageError, initial, closing bool, delivery ...*protocolDelivery) { - m.report(s, msg, err) - kind := diam.ProtocolInvalidRequest - if base.ValidateHeader(msg) != nil { - kind = diam.ProtocolInvalidHeader - } - if msg.Header.CommandFlags&diam.RequestFlag == 0 { - if kind != diam.ProtocolInvalidHeader { - kind = base.AnswerErrorKind(msg) - } - m.protocolError(s.c, msg, kind, err.ResultCode, false, diam.ProtocolReplyNone, 0, delivery...) - return - } - rejection := &base.RequestError{Kind: kind, Code: err.ResultCode} - if err.FailedAVP != nil { - rejection.Failed = []*diam.AVP{err.FailedAVP} - } - m.rejectRequest(s, msg, rejection, initial, closing, delivery...) -} - -func (m *Manager) rejectRequest(s *session, msg *diam.Message, rejection *base.RequestError, initial, closing bool, delivery ...*protocolDelivery) { - delivery, report := m.requiredReplyDelivery(delivery) - defer report() - if closing { - m.protocolError(s.c, msg, rejection.Kind, rejection.Code, false, diam.ProtocolReplyNone, 0, delivery...) - return - } - if initial { - s.terminal.Store(true) - } - answer, err := base.BuildErrorAnswer(msg, m.baseSettings(s.c), rejection.Code, rejection.Failed, rejection.Code >= 3000 && rejection.Code < 4000) - if err == nil { - err = s.sendRequired(answer, msg, initial, rejection.Code, delivery...) - } - m.reportLocal(s, msg, "peer: request rejection answer failed", err) - reply := diam.ProtocolReplyQueued - if err != nil { - reply = diam.ProtocolReplyFailed - s.close() - } - m.protocolError(s.c, msg, rejection.Kind, rejection.Code, initial || err != nil, reply, rejection.Code, delivery...) -} diff --git a/diam/peer/watchdog.go b/diam/peer/watchdog.go index d90fd2a..f40258d 100644 --- a/diam/peer/watchdog.go +++ b/diam/peer/watchdog.go @@ -6,10 +6,6 @@ import ( "math/rand" "time" - "github.com/gomaja/go-diameter/diam" - "github.com/gomaja/go-diameter/diam/avp" - "github.com/gomaja/go-diameter/diam/datatype" - "github.com/gomaja/go-diameter/diam/internal/base" "github.com/gomaja/go-diameter/diam/internal/watchdog" ) @@ -52,29 +48,6 @@ func (a *actor) stopWatchdog() { a.wdTimer = nil } } -func (a *actor) armWatchdog() { - a.armWatchdogAt(a.m.now()) -} - -func (a *actor) armWatchdogAt(receivedAt time.Time) { - a.stopWatchdog() - token := a.wdToken - gen := uint64(0) - if a.active != nil { - gen = a.active.gen - } - s := a.active - // RFC 3539 §3.4.1 and Appendix A SetWatchdog: Tw starts at receipt, - // not after ingress/actor backlog. An overdue reset fires immediately. - delay := max(time.Duration(0), receivedAt.Add(a.watchdogInterval()).Sub(a.m.now())) - a.wdTimer = a.m.cfg.Clock.AfterFunc(delay, func() { - barrier := uint64(0) - if s != nil { - barrier = s.receiveBarrier() - } - a.post(event{kind: watchdogTimeout, token: token, gen: gen, s: s, afterReceive: barrier}) - }) -} func (a *actor) stopReconnect() { a.tcToken++ if a.tcTimer != nil { @@ -144,34 +117,6 @@ func (a *actor) changeWatchdog(next WatchdogState, reason error) { } } -// watchdogCore is caller-owned storage; only the shared Step defines transitions. -func (a *actor) watchdogCore() watchdog.Machine { - return watchdog.Machine{State: watchdog.State(a.watchdog), Pending: a.pendingWatchdog, NumDWA: a.numDWA} -} - -func (a *actor) onOpen() { - // RFC 3539 Appendix A INITIAL/DOWN Connection up rows. - a.stopReconnect() - current := a.watchdogCore() - // A new admitted transport with no DOWN history is initial admission. - if current.State != watchdog.Down { - current.State = watchdog.Initial - } - next, actions := current.Step(watchdog.ConnectionUp) - a.numDWA = next.NumDWA - if actions.Send { - a.changeWatchdog(WatchdogState(next.State), errors.New("peer: recovered connection awaiting three DWA")) - a.sendWatchdog() - } else { - a.pendingWatchdog = next.Pending - a.changeWatchdog(WatchdogState(next.State), errors.New("peer: initial connection established")) - a.failures = 0 - } - a.everOpen = true - if a.state == IOpen || a.state == ROpen { - a.armWatchdog() - } -} func (a *actor) lostConnection(reason error) { // An initial handshake can end with transport EOF rather than a rejected // CEA. Complete its acquisition acknowledgement before reconnect backoff. @@ -180,193 +125,7 @@ func (a *actor) lostConnection(reason error) { a.pendingWatchdog = false // release the dead transport's outstanding probe if a.everOpen { // Transport closure and session failover are already owned by the caller. - next, _ := a.watchdogCore().Step(watchdog.ConnectionDown) - a.changeWatchdog(WatchdogState(next.State), reason) + a.changeWatchdog(WatchdogDown, reason) } a.scheduleReconnect() } -func (a *actor) sendWatchdog() { - if a.active == nil { - return - } - msg, err := base.BuildDWR(a.m.dictionary(), a.m.baseSettings(a.active.c), uint32(a.m.cfg.Settings.OriginStateID)) - if err != nil { - a.failLocal("build DWR", err) - return - } - if err := a.active.sendControl(msg); err != nil { - a.failLocal("send DWR", err) - return - } - a.pendingWatchdog = true - a.watchdogAnswered = false - a.watchdogHop = msg.Header.HopByHopID - a.watchdogEnd = msg.Header.EndToEndID - a.watchdogGen = a.active.gen -} -func (a *actor) watchdogTick() { - next, actions := a.watchdogCore().Step(watchdog.Expired) - a.numDWA = next.NumDWA - if actions.Open { - // Disconnected peers use the caller's Tc/backoff policy, not a live Tw. - a.reconnectTick() - return - } - if actions.Close { - reason := errors.New("peer: watchdog suspect timeout") - if a.watchdog == WatchdogReopen { - reason = errors.New("peer: recovery DWR unanswered") - } - connection := a.active - // fail owns close, Pending cleanup and DOWN publication. Do not publish - // the candidate before its transport lifecycle actions have completed. - a.fail(reason) - if connection != nil { - a.protocolError(connection.c, nil, diam.ProtocolWatchdogTimeout, 0, true, diam.ProtocolReplyNone, 0) - } - return - } - if actions.Failover { - a.changeWatchdog(WatchdogState(next.State), errors.New("peer: unanswered DWR")) - } - if actions.Send { - a.sendWatchdog() - } - // A synchronous build/queue failure can move the actor to DOWN. Never - // overwrite it with the candidate or rearm a timer for the dead transport. - if actions.Reset && a.watchdog == WatchdogState(next.State) { - a.armWatchdog() - } -} -func (a *actor) watchdogReceive(msg *diam.Message) bool { - return a.watchdogReceiveAt(msg, a.m.now()) -} - -func (a *actor) watchdogReceiveAt(msg *diam.Message, receivedAt time.Time) bool { - if receivedAt.IsZero() { - receivedAt = a.m.now() - } - event := watchdog.Receive - isDWA := msg.Header.CommandCode == diam.DeviceWatchdog && msg.Header.CommandFlags&diam.RequestFlag == 0 - if isDWA { - event = watchdog.ReceiveDWA - } - next, actions := a.watchdogCore().Step(event) - // Preserve receive ordering: OKAY/SUSPECT activity resets Tw even if a DWA - // is subsequently rejected. Commit its state changes only after validation. - // RFC 3539 Appendix A REOPEN receives do not postpone recovery probes. - if actions.Reset { - a.armWatchdogAt(receivedAt) - } - if !isDWA { - if actions.Failback { - a.changeWatchdog(WatchdogState(next.State), errors.New("peer: traffic restored watchdog health")) - a.failures = 0 - } - return true - } - if msg.Header.ApplicationID != 0 { - a.m.report(a.active, msg, errors.New("peer: DWA application ID is not zero")) - a.protocolError(a.active.c, msg, diam.ProtocolInvalidDWA, 0, false, diam.ProtocolReplyNone, 0) - return false - } - if err := base.ValidateMessage(msg); err != nil { - a.m.rejectBaseMessage(a.active, msg, err, false, false, a.delivery) - return false - } - if !a.pendingWatchdog || a.active == nil || a.watchdogGen != a.active.gen || msg.Header.HopByHopID != a.watchdogHop || msg.Header.EndToEndID != a.watchdogEnd { - a.m.report(a.active, msg, errors.New("peer: unsolicited or mismatched DWA")) - if a.active != nil { - a.protocolError(a.active.c, msg, diam.ProtocolInvalidDWA, 0, false, diam.ProtocolReplyNone, 0) - } - return false - } - if err := a.validateDWA(msg); err != nil { - // RFC 6733 §5.4: a structurally valid, correlated unsuccessful - // DWA is an answer to in-flight work, without granting the existing - // RFC 3539 probe credit or changing Pending/recovery policy. - if errors.Is(err, errDWAResultCode) && a.validateDWAResponse(msg, false) == nil { - a.watchdogAnswered = true - if a.active.requests != nil { - a.active.requests.MatchAnswer(msg, true) - } - } - a.m.report(a.active, msg, err) - if a.m.cfg.Settings.OnProtocolError != nil { - kind, result := diam.ProtocolInvalidDWA, uint32(0) - if code := protocolResultCode(err); errors.Is(err, errDWAResultCode) && code != 0 && code != diam.Success { - kind, result = diam.ProtocolRejectedDWA, code - } - a.protocolError(a.active.c, msg, kind, result, false, diam.ProtocolReplyNone, 0) - } - return false - } - a.watchdogAnswered = true - if a.active.requests != nil { - a.active.requests.MatchAnswer(msg, true) - } - a.pendingWatchdog = next.Pending - a.active.releaseControl(a.watchdogHop) - a.numDWA = next.NumDWA - if actions.Failback { - reason := errors.New("peer: valid DWA restored watchdog health") - if a.watchdog == WatchdogReopen { - reason = errors.New("peer: three valid recovery DWA") - } - a.changeWatchdog(WatchdogState(next.State), reason) - a.failures = 0 - } - return true -} - -func (a *actor) validateDWA(msg *diam.Message) error { - return a.validateDWAResponse(msg, true) -} - -// validateDWAResponse preserves successful-probe validation and optionally -// checks response structure alone for the Closing in-flight indication. -func (a *actor) validateDWAResponse(msg *diam.Message, successOnly bool) error { - // RFC 6733 §5.5.2: one success Result-Code and one identity pair. - // RFC 6733 §3: receivers ignore reserved bits; the defined command - // flags must still be clear in a successful DWA. - flags := msg.Header.CommandFlags - if flags&(diam.RequestFlag|diam.RetransmittedFlag) != 0 || flags&diam.ProxiableFlag != 0 && flags&diam.ErrorFlag == 0 { - return errors.New("peer: invalid DWA flags") - } - var hosts, realms, results int - for _, field := range msg.AVP { - if field.VendorID != 0 { - continue - } - switch field.Code { - case avp.OriginHost: - hosts++ - value, ok := field.Data.(datatype.DiameterIdentity) - if !ok || compareIdentity(value, a.cfg.Host) != 0 { - return errors.New("peer: DWA Origin-Host mismatch") - } - case avp.OriginRealm: - realms++ - value, ok := field.Data.(datatype.DiameterIdentity) - if !ok || len(value) == 0 || a.meta != nil && compareIdentity(value, a.meta.OriginRealm) != 0 { - return errors.New("peer: DWA Origin-Realm mismatch") - } - case avp.ResultCode: - results++ - value, ok := field.Data.(datatype.Unsigned32) - if !ok || successOnly && uint32(value) != diam.Success { - if a.m.cfg.Settings.OnProtocolError == nil { - return errDWAResultCode - } - return &dwaResultCodeError{result: uint32(value)} - } - } - } - if hosts != 1 || realms != 1 || results != 1 { - return errors.New("peer: DWA requires one Result-Code, Origin-Host and Origin-Realm") - } - if successOnly && msg.Header.CommandFlags&diam.ErrorFlag != 0 { - return errors.New("peer: invalid successful DWA flags") - } - return nil -} diff --git a/diam/peer/watchdog_delivery_test.go b/diam/peer/watchdog_delivery_test.go index 4ba9ceb..78f518b 100644 --- a/diam/peer/watchdog_delivery_test.go +++ b/diam/peer/watchdog_delivery_test.go @@ -29,7 +29,6 @@ func TestManagerWatchdogExpiryWaitsForAdmittedReceives(t *testing.T) { t.Fatal(err) } } - a.armWatchdog() if err := s.enqueue(msg); err != nil { t.Fatal(err) } @@ -54,7 +53,7 @@ func TestManagerWatchdogExpiryWaitsForAdmittedReceives(t *testing.T) { } } -func TestManagerWatchdogDeferredExpiryReplaysWithoutReceiveReset(t *testing.T) { +func TestManagerWatchdogDecodedReceiveInvalidatesDeferredExpiry(t *testing.T) { for _, recovery := range []bool{false, true} { t.Run(map[bool]string{false: "invalid header", true: "REOPEN DWA"}[recovery], func(t *testing.T) { state := WatchdogOkay @@ -65,33 +64,25 @@ func TestManagerWatchdogDeferredExpiryReplaysWithoutReceiveReset(t *testing.T) { s.ingress = make(chan incoming, 8) answer := testDWA(t, a) if !recovery { - answer.Header.ApplicationID = 4 // rejected before watchdogReceive + answer.Header.ApplicationID = 4 // decoded rejection resets Tw without credit } - a.armWatchdog() if err := s.enqueue(answer); err != nil { t.Fatal(err) } clock.fire() a.handle(<-a.events) - if a.deferredWatchdog == nil || !a.pendingWatchdog || len(s.writes) != 0 { + if !a.pendingWatchdog || len(s.writes) != 0 { t.Fatal("expiry did not wait for admitted answer") } in := <-s.ingress a.m.processDIAMAt(s, in.msg, in.receivedAt, in.sequence) a.handle(<-a.events) - if a.deferredWatchdog != nil { - t.Fatal("receive without Reset left expiry deferred forever") - } if recovery { - if a.watchdog != WatchdogReopen || a.numDWA != 1 || !a.pendingWatchdog || len(s.writes) != 1 { - t.Fatalf("credited recovery then expiry: state=%s NumDWA=%d Pending=%v writes=%d", a.watchdog, a.numDWA, a.pendingWatchdog, len(s.writes)) - } - probe := (<-s.writes).msg - if probe.Header.CommandCode != diam.DeviceWatchdog || probe.Header.CommandFlags&diam.RequestFlag == 0 { - t.Fatal("deferred expiry did not publish the next recovery probe") + if a.watchdog != WatchdogReopen || a.numDWA != 1 || a.pendingWatchdog || len(s.writes) != 0 { + t.Fatalf("credited recovery retained expired work: state=%s NumDWA=%d Pending=%v writes=%d", a.watchdog, a.numDWA, a.pendingWatchdog, len(s.writes)) } - } else if a.watchdog != WatchdogSuspect || !a.pendingWatchdog || len(s.writes) != 0 { - t.Fatalf("invalid answer postponed expiry: state=%s Pending=%v writes=%d", a.watchdog, a.pendingWatchdog, len(s.writes)) + } else if a.watchdog != WatchdogOkay || !a.pendingWatchdog || len(s.writes) != 0 { + t.Fatalf("invalid decoded answer failed reset-only receipt: state=%s Pending=%v writes=%d", a.watchdog, a.pendingWatchdog, len(s.writes)) } }) } @@ -103,15 +94,14 @@ func TestManagerWatchdogDeferredExpiryStopsWithGeneration(t *testing.T) { a, s, clock := newWatchdogTestActor(t, WatchdogReopen) s.ingress = make(chan incoming, 8) answer := testDWA(t, a) - a.armWatchdog() if err := s.enqueue(answer); err != nil { t.Fatal(err) } clock.fire() expiry := <-a.events a.handle(expiry) - if a.deferredWatchdog == nil { - t.Fatal("expiry not deferred") + if !a.pendingWatchdog || a.numDWA != 0 { + t.Fatal("expiry overtook admitted recovery answer") } if retire { s.retired.Store(true) @@ -119,9 +109,7 @@ func TestManagerWatchdogDeferredExpiryStopsWithGeneration(t *testing.T) { } else { a.stop(sm.DisconnectRebooting) } - if a.deferredWatchdog != nil { - t.Fatal("ended generation retained deferred expiry") - } + queued := len(s.writes) in := <-s.ingress a.handle(event{kind: wireEvent, s: s, msg: in.msg, receivedAt: in.receivedAt, receiveSequence: in.sequence}) @@ -139,6 +127,7 @@ func TestManagerWatchdogReceiptBacklogDoesNotExtendTw(t *testing.T) { synctest.Test(t, func(t *testing.T) { a, s, clock := newWatchdogTestActor(t, WatchdogOkay) a.m.cfg.Clock = receiptClock{clock} + a.m.cfg.Now = time.Now s.ingress = make(chan incoming, 8) a.m.mu.Lock() a.m.sessions[s.c] = s diff --git a/diam/peer/watchdog_fuzz_test.go b/diam/peer/watchdog_fuzz_test.go index d38f12a..93b268f 100644 --- a/diam/peer/watchdog_fuzz_test.go +++ b/diam/peer/watchdog_fuzz_test.go @@ -36,7 +36,14 @@ func FuzzManagedDWA(f *testing.F) { if err != nil || msg == nil || msg.Header == nil || msg.Header.CommandCode != diam.DeviceWatchdog || msg.Header.ApplicationID != 0 || msg.Header.CommandFlags&diam.RequestFlag != 0 { return } - a := actor{cfg: PeerConfig{Host: "known.example.net"}} - _ = a.validateDWA(msg) + a, s, _ := newWatchdogTestActor(t, WatchdogReopen) + // Keep the fuzzer's header/body variation while installing only the owned + // exchange coordinates, so validation proceeds through the production core. + valid := testDWA(t, a) + msg.Header.HopByHopID, msg.Header.EndToEndID = valid.Header.HopByHopID, valid.Header.EndToEndID + a.onWire(event{s: s, msg: msg}) + if snapshot := s.protocolSnapshot(); snapshot.Watchdog().NumDWA > 1 { + t.Fatalf("one fuzz input earned multiple recovery credits: %+v", snapshot.Watchdog()) + } }) } diff --git a/diam/peer/watchdog_test.go b/diam/peer/watchdog_test.go index ca2e038..9e26596 100644 --- a/diam/peer/watchdog_test.go +++ b/diam/peer/watchdog_test.go @@ -3,6 +3,7 @@ package peer import ( "context" "errors" + "fmt" "net" "strings" "sync/atomic" @@ -57,115 +58,154 @@ func newWatchdogTestActor(t *testing.T, state WatchdogState) (*actor, *session, s := &session{m: m, c: newFakeConn(), actor: a, gen: 7, writes: make(chan writeRequest, 8), closed: make(chan struct{})} a.r, a.active = s, s a.publish(nil) + if state != WatchdogInitial && state != WatchdogDown { + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } + } t.Cleanup(func() { a.stopWatchdog(); a.stopReconnect(); s.close(); closeManager(t, m, nil) }) return a, s, clock } func testDWA(t *testing.T, a *actor) *diam.Message { t.Helper() - request, err := base.BuildDWR(a.m.dictionary(), testBase("local.example.net"), 0) - if err != nil { - t.Fatal(err) - } - a.pendingWatchdog, a.watchdogHop, a.watchdogEnd, a.watchdogGen = true, request.Header.HopByHopID, request.Header.EndToEndID, a.active.gen - answer, err := base.BuildDWA(request, testBase("known.example.net")) - if err != nil { - t.Fatal(err) + ensureSessionProtocol(a.active) + if !a.active.protocolSnapshot().Watchdog().Pending { + a.sendWatchdog() + } + if len(a.active.writes) > 0 { + fixtureConsumeProbe(a.active) + } + for _, control := range a.active.protocolSnapshot().Controls() { + if control.Command == diam.DeviceWatchdog && control.Logical { + request := diam.NewMessage(diam.DeviceWatchdog, diam.RequestFlag, 0, control.Reservation.Hop, control.Reservation.End, a.m.dictionary()) + request.Header.HopByHopID, request.Header.EndToEndID = control.Reservation.Hop, control.Reservation.End + answer, err := base.BuildDWA(request, testBase("known.example.net")) + if err != nil { + t.Fatal(err) + } + return answer + } } - return answer + t.Fatal("core has no owned DWR") + return nil } func TestValidateDWACommandFlags(t *testing.T) { - a, _, _ := newWatchdogTestActor(t, WatchdogOkay) - answer := testDWA(t, a) for flags := 0; flags <= 255; flags++ { - answer.Header.CommandFlags = uint8(flags) - err := a.validateDWA(answer) - // RFC 6733 §§3 and 5.5.2: ignore the reserved low bits, but - // a successful DWA still cannot carry any defined command flag. - wantError := flags&0xf0 != 0 - if (err != nil) != wantError { - t.Errorf("DWA flags %#02x: error = %v, want error %t", flags, err, wantError) - } + t.Run(fmt.Sprintf("%02x", flags), func(t *testing.T) { + a, _, _ := newWatchdogTestActor(t, WatchdogOkay) + answer := testDWA(t, a) + answer.Header.CommandFlags = uint8(flags) + a.watchdogReceive(answer) + valid := !a.pendingWatchdog + // RFC 6733 §§3 and 5.5.2: ignore reserved low bits; successful + // DWA must have all four defined command flags clear. + if want := flags&0xf0 == 0; valid != want { + t.Fatalf("DWA flags %#02x: credit=%t, want %t", flags, valid, want) + } + }) } } func TestWatchdogReceiveReservedDWAFlags(t *testing.T) { - a, _, _ := newWatchdogTestActor(t, WatchdogReopen) for reserved := uint8(0); reserved <= 0x0f; reserved++ { - a.numDWA = 0 - answer := testDWA(t, a) - answer.Header.CommandFlags = reserved - if !a.watchdogReceive(answer) || a.pendingWatchdog || a.numDWA != 1 { - t.Fatalf("reserved DWA flags %#02x: pending=%t recovery count=%d", reserved, a.pendingWatchdog, a.numDWA) - } + t.Run(fmt.Sprintf("%02x", reserved), func(t *testing.T) { + a, _, _ := newWatchdogTestActor(t, WatchdogReopen) + answer := testDWA(t, a) + answer.Header.CommandFlags = reserved + if !a.watchdogReceive(answer) || a.pendingWatchdog || a.numDWA != 1 { + t.Fatalf("reserved DWA flags %#02x: pending=%t recovery count=%d", reserved, a.pendingWatchdog, a.numDWA) + } + }) } } -// Every row is named after the RFC 3539 Appendix A state/event/guard. +// RFC 3539 Appendix A admitted rows are exercised through production core +// timers and receipts. INITIAL/DOWN transport admission is covered separately +// by Manager's handshake/reconnect tests rather than fabricated DWA ownership. func TestRFC3539WatchdogTable(t *testing.T) { - type row struct { - name string - state WatchdogState - event string - pending bool - num int - want WatchdogState - wantPending bool - wantNum int - writes int - } - rows := []row{ - {"INITIAL/connection up", WatchdogInitial, "up", false, 0, WatchdogOkay, false, 0, 0}, - {"INITIAL/Tw expires", WatchdogInitial, "timer", false, 0, WatchdogInitial, false, 0, 0}, - {"INITIAL/DWA", WatchdogInitial, "dwa", true, 0, WatchdogInitial, false, 0, 0}, - {"INITIAL/other message", WatchdogInitial, "other", false, 0, WatchdogInitial, false, 0, 0}, - {"OKAY/valid DWA", WatchdogOkay, "dwa", true, 0, WatchdogOkay, false, 0, 0}, - {"OKAY/other message", WatchdogOkay, "other", true, 0, WatchdogOkay, true, 0, 0}, - {"OKAY/Tw expires no pending", WatchdogOkay, "timer", false, 0, WatchdogOkay, true, 0, 1}, - {"OKAY/Tw expires pending", WatchdogOkay, "timer", true, 0, WatchdogSuspect, true, 0, 0}, - {"OKAY/connection down", WatchdogOkay, "down", false, 0, WatchdogDown, false, 0, 0}, - {"SUSPECT/valid DWA", WatchdogSuspect, "dwa", true, 0, WatchdogOkay, false, 0, 0}, - {"SUSPECT/other message", WatchdogSuspect, "other", true, 0, WatchdogOkay, true, 0, 0}, - {"SUSPECT/Tw expires", WatchdogSuspect, "timer", true, 0, WatchdogDown, false, 0, 0}, - {"SUSPECT/connection down", WatchdogSuspect, "down", true, 0, WatchdogDown, false, 0, 0}, - {"DOWN/Tw expires", WatchdogDown, "timer", false, 0, WatchdogDown, false, 0, 0}, - {"DOWN/connection up", WatchdogDown, "up", false, 9, WatchdogReopen, true, 0, 1}, - {"DOWN/DWA", WatchdogDown, "dwa", true, 0, WatchdogDown, false, 0, 0}, - {"DOWN/other message", WatchdogDown, "other", false, 0, WatchdogDown, false, 0, 0}, - {"REOPEN/valid DWA NumDWA 0", WatchdogReopen, "dwa", true, 0, WatchdogReopen, false, 1, 0}, - {"REOPEN/valid DWA NumDWA 1", WatchdogReopen, "dwa", true, 1, WatchdogReopen, false, 2, 0}, - {"REOPEN/valid DWA NumDWA 2", WatchdogReopen, "dwa", true, 2, WatchdogOkay, false, 3, 0}, - {"REOPEN/other message", WatchdogReopen, "other", true, 0, WatchdogReopen, true, 0, 0}, - {"REOPEN/Tw expires no pending", WatchdogReopen, "timer", false, 0, WatchdogReopen, true, 0, 1}, - {"REOPEN/Tw expires pending first", WatchdogReopen, "timer", true, 0, WatchdogReopen, true, -1, 0}, - {"REOPEN/Tw expires pending second", WatchdogReopen, "timer", true, -1, WatchdogDown, false, -1, 0}, - {"REOPEN/connection down", WatchdogReopen, "down", true, 1, WatchdogDown, false, 1, 0}, - } - for _, tc := range rows { + for _, tc := range []struct { + name string + state WatchdogState + pending bool + event string + want WatchdogState + wantPending bool + wantNum, writes int + }{ + {"OKAY/Tw expires no pending", WatchdogOkay, false, "timer", WatchdogOkay, true, 0, 1}, + {"OKAY/Tw expires pending", WatchdogOkay, true, "timer", WatchdogSuspect, true, 0, 0}, + {"OKAY/valid DWA", WatchdogOkay, true, "dwa", WatchdogOkay, false, 0, 0}, + {"OKAY/valid other message", WatchdogOkay, true, "other", WatchdogOkay, true, 0, 1}, + {"SUSPECT/valid DWA", WatchdogSuspect, true, "dwa", WatchdogOkay, false, 0, 0}, + {"SUSPECT/valid other message", WatchdogSuspect, true, "other", WatchdogOkay, true, 0, 1}, + {"SUSPECT/Tw expires", WatchdogSuspect, true, "timer", WatchdogDown, false, 0, 0}, + {"REOPEN/first valid DWA", WatchdogReopen, true, "dwa", WatchdogReopen, false, 1, 0}, + {"REOPEN/valid other message", WatchdogReopen, true, "other", WatchdogReopen, true, 0, 1}, + {"REOPEN/first unanswered expiry", WatchdogReopen, true, "timer", WatchdogReopen, true, -1, 0}, + } { t.Run(tc.name, func(t *testing.T) { a, s, _ := newWatchdogTestActor(t, tc.state) - a.pendingWatchdog, a.numDWA = tc.pending, tc.num + if tc.pending && !a.pendingWatchdog { + a.sendWatchdog() + fixtureConsumeProbe(s) + } switch tc.event { - case "up": - a.onOpen() - case "down": - a.onGone(s) case "timer": a.watchdogTick() case "dwa": a.watchdogReceive(testDWA(t, a)) case "other": - a.watchdogReceive(diam.NewRequest(999, 0, a.m.dictionary())) + a.watchdogReceive(watchdogRequest(t)) } if a.watchdog != tc.want || a.pendingWatchdog != tc.wantPending || a.numDWA != tc.wantNum { - t.Fatalf("watchdog=%s pending=%v NumDWA=%d; want %s %v %d", a.watchdog, a.pendingWatchdog, a.numDWA, tc.want, tc.wantPending, tc.wantNum) + t.Fatalf("watchdog=%s pending=%t NumDWA=%d; want %s %t %d", a.watchdog, a.pendingWatchdog, a.numDWA, tc.want, tc.wantPending, tc.wantNum) } if len(s.writes) != tc.writes { t.Fatalf("writes=%d want %d", len(s.writes), tc.writes) } - if got := a.snapshot().Eligible; got != (a.state == ROpen && tc.want == WatchdogOkay) { - t.Fatalf("Eligible=%v", got) + if got, want := a.snapshot().Eligible, a.state == ROpen && tc.want == WatchdogOkay; got != want { + t.Fatalf("Eligible=%t want %t", got, want) + } + }) + } +} + +func TestWatchdogRecoveryCreditAndSecondExpiry(t *testing.T) { + for _, prior := range []int{1, 2} { + t.Run(fmt.Sprintf("credited=%d", prior), func(t *testing.T) { + a, _, _ := newWatchdogTestActor(t, WatchdogReopen) + for range prior { + a.watchdogReceive(testDWA(t, a)) + } + a.watchdogReceive(testDWA(t, a)) + if a.numDWA != prior+1 || a.pendingWatchdog || a.snapshot().Eligible != (prior == 2) { + t.Fatalf("recovery credit state=%s NumDWA=%d Pending=%t Eligible=%t", a.watchdog, a.numDWA, a.pendingWatchdog, a.snapshot().Eligible) + } + }) + } + t.Run("second unanswered expiry", func(t *testing.T) { + a, _, _ := newWatchdogTestActor(t, WatchdogReopen) + a.watchdogTick() + if a.numDWA != -1 || !a.pendingWatchdog { + t.Fatalf("first unanswered expiry state=%s NumDWA=%d Pending=%t", a.watchdog, a.numDWA, a.pendingWatchdog) + } + a.watchdogTick() + if a.watchdog != WatchdogDown || a.pendingWatchdog || a.snapshot().Eligible { + t.Fatalf("second unanswered expiry state=%s Pending=%t Eligible=%t", a.watchdog, a.pendingWatchdog, a.snapshot().Eligible) + } + }) + for _, history := range []WatchdogState{WatchdogInitial, WatchdogDown} { + t.Run(string(history)+"/connection up", func(t *testing.T) { + a, s, _ := newWatchdogTestActor(t, history) + a.onOpen() + if history == WatchdogInitial { + if a.watchdog != WatchdogOkay || a.pendingWatchdog || !a.snapshot().Eligible || len(s.writes) != 0 { + t.Fatalf("initial admission state=%s Pending=%t writes=%d", a.watchdog, a.pendingWatchdog, len(s.writes)) + } + } else if a.watchdog != WatchdogReopen || !a.pendingWatchdog || a.numDWA != 0 || a.snapshot().Eligible || len(s.writes) != 1 { + t.Fatalf("recovery admission state=%s Pending=%t NumDWA=%d writes=%d", a.watchdog, a.pendingWatchdog, a.numDWA, len(s.writes)) } }) } @@ -195,7 +235,7 @@ func TestWatchdogMatchingAndRecovery(t *testing.T) { if !a.pendingWatchdog || a.numDWA != 0 || a.snapshot().Eligible { t.Fatal("recovery opened before validation") } - first := (<-s.writes).msg + first := fixtureConsumeProbe(s) answer := func(request *diam.Message) *diam.Message { t.Helper() m, err := base.BuildDWA(request, testBase("known.example.net")) @@ -220,7 +260,7 @@ func TestWatchdogMatchingAndRecovery(t *testing.T) { } for want := 2; want <= 3; want++ { a.watchdogTick() - probe := (<-s.writes).msg + probe := fixtureConsumeProbe(s) a.onWire(event{s: s, msg: answer(probe)}) if a.numDWA != want || a.snapshot().Eligible != (want == 3) { t.Fatalf("NumDWA=%d Eligible=%v", a.numDWA, a.snapshot().Eligible) @@ -233,22 +273,22 @@ func TestWatchdogMatchingAndRecovery(t *testing.T) { func TestWatchdogReopenSuppressesApplicationButAnswersDWR(t *testing.T) { a, s, clock := newWatchdogTestActor(t, WatchdogReopen) - a.armWatchdog() tw := clock.timers[len(clock.timers)-1] a.onWire(event{s: s, msg: diam.NewRequest(999, 0, a.m.dictionary())}) if len(s.writes) != 0 { t.Fatal("application message dispatched in REOPEN") } - if !tw.active { - t.Fatal("REOPEN traffic postponed the next recovery probe") + if tw.active { + t.Fatal("decoded REOPEN traffic did not reset its recovery timer") } + tw = clock.timers[len(clock.timers)-1] dwr, err := base.BuildDWR(a.m.dictionary(), testBase("known.example.net"), 0) if err != nil { t.Fatal(err) } a.onWire(event{s: s, msg: dwr}) - if !tw.active { - t.Fatal("peer DWR postponed the next recovery probe") + if tw.active { + t.Fatal("peer DWR did not reset the recovery timer") } if len(s.writes) != 1 || (<-s.writes).msg.Header.CommandFlags&diam.RequestFlag != 0 { t.Fatal("peer DWR not answered") @@ -257,7 +297,6 @@ func TestWatchdogReopenSuppressesApplicationButAnswersDWR(t *testing.T) { func TestWatchdogTrafficResetsTwAndJitter(t *testing.T) { a, _, clock := newWatchdogTestActor(t, WatchdogOkay) - a.armWatchdog() first := clock.timers[len(clock.timers)-1] a.watchdogReceive(diam.NewRequest(999, 0, a.m.dictionary())) if first.active { @@ -269,8 +308,7 @@ func TestWatchdogTrafficResetsTwAndJitter(t *testing.T) { a.m.cfg.Timers.TwInit = 6 * time.Second a.m.cfg.watchdogTiming = nil for i := 0; i < 200; i++ { - a.armWatchdog() - d := clock.timers[len(clock.timers)-1].duration + d := a.watchdogInterval() if d < 4*time.Second || d > 8*time.Second { t.Fatalf("Tw jitter %s outside ±2s", d) } @@ -301,10 +339,10 @@ func TestReconnectBackoffAndPolicy(t *testing.T) { } a.state = ROpen a.r, a.active = s, s - a.watchdog = WatchdogReopen - a.numDWA = 2 a.publish(nil) - a.watchdogReceive(testDWA(t, a)) + for range 3 { + a.watchdogReceive(testDWA(t, a)) + } if a.failures != 0 || !a.snapshot().Eligible { t.Fatal("stable OKAY did not reset Tc") } @@ -457,8 +495,9 @@ func TestWatchdogDWAIdentityAndResult(t *testing.T) { func TestWatchdogStaleGenerationDWA(t *testing.T) { a, s, _ := newWatchdogTestActor(t, WatchdogReopen) m := testDWA(t, a) - a.watchdogGen = s.gen - 1 - a.onWire(event{s: s, msg: m}) + old := &session{m: a.m, c: newFakeConn(), actor: a, gen: s.gen - 1, writes: make(chan writeRequest, 8), closed: make(chan struct{})} + t.Cleanup(old.close) + a.onWire(event{s: old, msg: m}) if a.numDWA != 0 || !a.pendingWatchdog { t.Fatal("old-generation DWA counted") } diff --git a/diam/peer/writer_completion_test.go b/diam/peer/writer_completion_test.go new file mode 100644 index 0000000..07aea2a --- /dev/null +++ b/diam/peer/writer_completion_test.go @@ -0,0 +1,46 @@ +package peer + +import ( + "testing" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +func TestSuccessfulAutomaticReplyCompletesWithoutActorEvent(t *testing.T) { + m, err := New(Config{Settings: testSettings("local.example.net"), Clock: &fakeClock{}}) + if err != nil { + t.Fatal(err) + } + a := &actor{m: m, cfg: PeerConfig{Host: "known.example.net", NoAutoReconnect: true}, state: ROpen, watchdog: WatchdogOkay} + s := &session{m: m, c: newFakeConn(), actor: a, gen: 3, requests: new(diam.PendingRequests)} + if err := bootstrapSessionProtocol(s); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.close(); closeManager(t, m, nil) }) + s.applyProtocol(protocol.Input{Kind: protocol.Receive, Message: watchdogRequest(t)}, nil) + var w writeRequest + select { + case w = <-s.writes: + default: + t.Fatal("DWR did not select DWA") + } + if len(a.events) != 0 { + t.Fatal("reply queue acceptance posted an actor event") + } + if s.writeProtocol(w) { + t.Fatal("successful reply closed connection") + } + if len(a.events) != 0 { + t.Fatal("successful automatic reply woke the actor") + } + s.coreMu.Lock() + writes, headers := len(s.coreWrites), len(s.coreHeaders) + s.coreMu.Unlock() + if writes != 0 || headers != 0 { + t.Fatal("successful reply retained writer ownership") + } + if s.protocolSnapshot().Phase() != protocol.Open { + t.Fatal("successful reply changed connection phase") + } +} diff --git a/diam/sm/accepted_core.go b/diam/sm/accepted_core.go index 6e64cc0..20dc37a 100644 --- a/diam/sm/accepted_core.go +++ b/diam/sm/accepted_core.go @@ -121,7 +121,7 @@ func (sm *StateMachine) acceptedCoreWithAdmission(c diam.Conn, m *diam.Message, Local: cfg, Dictionary: sm.capabilityDictionary(c, m), TLS: c.TLS() != nil, Hooks: true, HandshakeTimeout: d, ClosingTimeout: closingTimeout, LocalDisconnectInClosing: true, - DWRHookCompatibility: true, + SkipDWRGrammarAfterHook: true, Watchdog: sm.watchdog != nil, WatchdogInterval: interval, ValidateRequests: sm.cfg.ValidateRequests, RejectUnknownMandatoryAVPs: sm.cfg.RejectUnknownMandatoryAVPs} var core *protocol.Core @@ -328,7 +328,7 @@ func (s *acceptedHandshake) prepareLocked(effects []protocol.Effect, releaseDisp timer.Stop() } case protocol.Hold: - s.held = append(s.held, e.Input) + s.held = append(s.held, *e.Input) case protocol.CancelSend: if ticket := s.writes[e.Token]; ticket != nil { ticket.Cancel() diff --git a/diam/sm/accepted_effect_storage_test.go b/diam/sm/accepted_effect_storage_test.go index 959724c..72a8a72 100644 --- a/diam/sm/accepted_effect_storage_test.go +++ b/diam/sm/accepted_effect_storage_test.go @@ -16,7 +16,7 @@ func TestPreparedEffectsReuseOwnedStorage(t *testing.T) { s := &acceptedHandshake{} effects := []protocol.Effect{ {Kind: protocol.StopTimer, Timer: protocol.WatchdogTimer, ReleaseDispatch: true}, - {Kind: protocol.Hold, Input: protocol.Input{Token: 91}}, + {Kind: protocol.Hold, Input: &protocol.Input{Token: 91}}, {Kind: protocol.Discard, Token: 92}, {Kind: protocol.Notify, Token: 93}, } @@ -103,7 +103,7 @@ func TestBatchObserversAllocateOnlyWhenRequired(t *testing.T) { compacted := s.batchLocked(in, []protocol.Effect{ {Kind: protocol.StopTimer, Timer: protocol.WatchdogTimer}, {Kind: protocol.Notify, Notification: protocol.Notification{Kind: protocol.WatchdogNotice}}, - {Kind: protocol.Hold, Input: protocol.Input{Token: 101}}, + {Kind: protocol.Hold, Input: &protocol.Input{Token: 101}}, {Kind: protocol.Discard, Token: 102}, {Kind: protocol.Notify, ReleaseDispatch: true, Notification: protocol.Notification{Kind: protocol.WatchdogNotice}}, }) diff --git a/diam/sm/accepted_effects.go b/diam/sm/accepted_effects.go index c082660..7b7b4f0 100644 --- a/diam/sm/accepted_effects.go +++ b/diam/sm/accepted_effects.go @@ -7,9 +7,9 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/parserbridge" "github.com/gomaja/go-diameter/diam/internal/protocol" "github.com/gomaja/go-diameter/diam/internal/watchdog" - "github.com/gomaja/go-diameter/diam/sm/internal/parserbridge" "github.com/gomaja/go-diameter/diam/sm/smparser" "github.com/gomaja/go-diameter/diam/sm/smpeer" ) diff --git a/diam/sm/accepted_log.go b/diam/sm/accepted_log.go index 02c1f65..c468c20 100644 --- a/diam/sm/accepted_log.go +++ b/diam/sm/accepted_log.go @@ -160,7 +160,10 @@ func (s *acceptedHandshake) record(in protocol.Input, n protocol.Notification) { if n.Diagnostic == "local-disconnect" { err = ErrDisconnectTimeout } - if (err == nil || n.Protocol == diam.ProtocolInvalidRequest || n.Protocol == diam.ProtocolUnsupportedAVP) && n.ResultCode != 0 { + // Construction failure reports the header classification publicly while + // the core retains the detached validation cause for its own consumers. + headerConstructionFailure := n.Protocol == diam.ProtocolInvalidHeader && n.Reply == diam.ProtocolReplyFailed && n.ReplyError != nil + if (err == nil || n.Protocol == diam.ProtocolInvalidRequest || n.Protocol == diam.ProtocolUnsupportedAVP || headerConstructionFailure) && n.ResultCode != 0 { err = diam.NewProtocolError(n.Protocol, n.ResultCode) } if n.Protocol == diam.ProtocolUnsupportedCommand || n.Protocol == diam.ProtocolUnsupportedApplication { diff --git a/diam/sm/client_core.go b/diam/sm/client_core.go index a19e64d..7c3545f 100644 --- a/diam/sm/client_core.go +++ b/diam/sm/client_core.go @@ -74,9 +74,9 @@ func (cli *Client) newCore(c diam.Conn, local base.Settings) (*acceptedHandshake core, err := protocol.New(protocol.Config{Connection: s.id, Role: protocol.Initiator, Local: local, Dictionary: cli.Dict, TLS: secured, Hooks: true, CERInterval: max(cli.RetransmitInterval, time.Nanosecond), MaxRetransmits: cli.MaxRetransmits, - ClosingTimeout: closingTimeout, LocalDisconnectInClosing: true, DWRHookCompatibility: true, - CERTransportLossCompatibility: true, - Watchdog: cli.EnableWatchdog, WatchdogInterval: interval, + ClosingTimeout: closingTimeout, LocalDisconnectInClosing: true, SkipDWRGrammarAfterHook: true, + KeepCERDeadlineAfterTransportLoss: true, + Watchdog: cli.EnableWatchdog, WatchdogInterval: interval, ValidateRequests: cli.Handler.cfg.ValidateRequests, RejectUnknownMandatoryAVPs: cli.Handler.cfg.RejectUnknownMandatoryAVPs}) if err != nil { return nil, acceptedBatch{}, err diff --git a/diam/sm/closing_request_rejection_test.go b/diam/sm/closing_request_rejection_test.go new file mode 100644 index 0000000..3ce55f4 --- /dev/null +++ b/diam/sm/closing_request_rejection_test.go @@ -0,0 +1,183 @@ +package sm + +import ( + "bytes" + "context" + "errors" + "log/slog" + "testing" + "testing/synctest" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/internal/logtest" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +type closingRejectionContextKey struct{} + +// A malformed simultaneous DPR cannot replace the caller's outstanding local +// disconnect deadline or select another reply. Retaining typed validation +// evidence internally preserves the public diagnostic's ProtocolError type. +func TestClosingRequestRejectionPreservesDisconnectDeadline(t *testing.T) { + for _, role := range []string{"accepted", "client"} { + for _, condition := range []string{"missing-origin", "mandatory-avp"} { + t.Run(role+"/"+condition, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + sm, c, logs, events, cleanup := newClosingRejectionPeer(t, role) + defer cleanup() + state := sm.acceptedCore(c, nil, false) + if state == nil { + t.Fatal("admitted connection has no protocol owner") + } + disconnected := make(chan error, 1) + go func() { disconnected <- sm.Disconnect(c, DisconnectBusy, time.Hour) }() + synctest.Wait() + defer func() { + c.Close() + synctest.Wait() + if err := <-disconnected; !errors.Is(err, ErrDisconnectClosed) { + t.Errorf("Disconnect = %v, want ErrDisconnectClosed", err) + } + }() + if state.snapshot().Phase() != protocol.Closing { + t.Fatal("Disconnect did not enter Closing") + } + if dpr := closingRejectionWrite(t, c); dpr.Header.CommandCode != diam.DisconnectPeer || dpr.Header.CommandFlags&diam.RequestFlag == 0 { + t.Fatalf("disconnect writer selected %v", dpr) + } + before := state.snapshot().Timer(protocol.ClosingTimer) + beforeLogs := len(logs.Records()) + time.Sleep(time.Second) + request := regressionDWR(t) + request.Header.CommandCode = diam.DisconnectPeer + request.AddAVP(diam.NewAVP(avp.DisconnectCause, avp.Mbit, 0, datatype.Unsigned32(0))) + kind, code, text := diam.ProtocolInvalidRequest, uint32(diam.MissingAVP), "sm: invalid request" + if condition == "missing-origin" { + var kept []*diam.AVP + for _, a := range request.AVP { + if a.Code != avp.OriginHost { + kept = append(kept, a) + } + } + request.AVP = kept + } else { + request.AddAVP(diam.NewAVP(0xfedcab, avp.Mbit, 0, datatype.Unknown([]byte{1, 2, 3, 4}))) + kind, code, text = diam.ProtocolUnsupportedAVP, diam.AVPUnsupported, "sm: unsupported mandatory AVP" + } + wire, err := request.Serialize() + if err != nil { + t.Fatal(err) + } + request, err = diam.ReadMessage(bytes.NewReader(wire), c.Dictionary()) + if err != nil { + t.Fatal(err) + } + requestContext := context.WithValue(context.Background(), closingRejectionContextKey{}, "request") + request.SetContext(requestContext) + sm.ServeDIAM(c, request) + synctest.Wait() + after := state.snapshot() + timer := after.Timer(protocol.ClosingTimer) + if c.Closed() || after.Phase() != protocol.Closing || len(c.writes) != 0 { + t.Fatalf("rejected DPR changed Closing: closed=%t phase=%v writes=%d", c.Closed(), after.Phase(), len(c.writes)) + } + if before.Token == 0 || timer.Token != before.Token || !timer.Deadline.Equal(before.Deadline) || time.Until(timer.Deadline) != time.Hour-time.Second { + t.Fatalf("rejected DPR changed the disconnect deadline: before=%+v after=%+v", before, timer) + } + if len(events) != 1 { + t.Fatalf("protocol notifications = %d, want one", len(events)) + } + event := <-events + assertProtocolEvent(t, event, c, request.Header, kind, code, false, diam.ProtocolReplyNone, 0) + records := logs.Records()[beforeLogs:] + if len(records) != 1 { + t.Fatalf("rejection logs = %v, want one", records) + } + record := records[0] + if record.Level != slog.LevelWarn || record.Message != text || logs.Contexts()[beforeLogs] != requestContext { + t.Fatalf("rejection changed log level, text or context: %v", record) + } + diagnostic, ok := logtest.Attr(record, "error").Any().(*diam.ProtocolError) + if !ok || diagnostic.Kind() != kind || diagnostic.ResultCode() != code || diagnostic.Error() != diam.NewProtocolError(kind, code).Error() { + t.Fatalf("rejection changed diagnostic type/value: %T %v", logtest.Attr(record, "error").Any(), logtest.Attr(record, "error").Any()) + } + if cause, ok := errors.Unwrap(diagnostic).(diam.ProtocolErrorKind); !ok || cause != kind { + t.Fatalf("diagnostic cause = %T %v", errors.Unwrap(diagnostic), errors.Unwrap(diagnostic)) + } + }) + }) + } + } +} + +func newClosingRejectionPeer(t *testing.T, role string) (*StateMachine, *errorDispositionConn, *logtest.Recorder, chan diam.ProtocolErrorEvent, func()) { + t.Helper() + logs := logtest.New() + c := &errorDispositionConn{handshakeConn: newHandshakeConn(), logger: logs.Logger(), done: make(chan struct{})} + cfg := testMessageErrorSettings(c.Dictionary()) + cfg.AcctApplicationID = []*diam.AVP{diam.NewAVP(avp.AcctApplicationID, avp.Mbit, 0, datatype.Unsigned32(1001))} + cfg.ValidateRequests, cfg.RejectUnknownMandatoryAVPs = true, true + cfg.EnableWatchdog, cfg.WatchdogInterval = true, time.Hour + cfg.watchdogTiming = &watchdogTiming{floor: time.Millisecond} + events := make(chan diam.ProtocolErrorEvent, 8) + cfg.OnProtocolError = func(e diam.ProtocolErrorEvent) { events <- e } + sm := mustNewStateMachine(t, cfg, c.Dictionary()) + var acceptCleanup func() + var cancel context.CancelFunc + if role == "accepted" { + acceptCleanup = sm.HandleAccept(c) + sm.ServeDIAM(c, regressionCER(t, c.Dictionary(), 1001)) + if cea := closingRejectionWrite(t, c); !testResultCode(cea, diam.Success) { + t.Fatalf("initial CEA = %v", cea) + } + } else { + cli := &Client{Handler: sm, Dict: c.Dictionary(), EnableWatchdog: true, WatchdogInterval: time.Hour, watchdogTiming: &watchdogTiming{floor: time.Millisecond}, RetransmitInterval: time.Hour} + var ctx context.Context + ctx, cancel = context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := cli.dialContext(ctx, func(*watchdogActivity) (diam.Conn, error) { return c, nil }) + result <- err + }() + synctest.Wait() + cer := closingRejectionWrite(t, c) + answer := protocolClientCEA(cer, diam.Success) + answer.AddAVP(diam.NewAVP(avp.AcctApplicationID, avp.Mbit, 0, datatype.Unsigned32(1001))) + setWireIdentity(t, answer, avp.OriginHost, string(clientSettings.OriginHost)) + setWireIdentity(t, answer, avp.OriginRealm, string(clientSettings.OriginRealm)) + sm.ServeDIAM(c, answer) + synctest.Wait() + if err := <-result; err != nil { + t.Fatalf("Client dial = %v", err) + } + } + synctest.Wait() + return sm, c, logs, events, func() { + c.Close() + if acceptCleanup != nil { + acceptCleanup() + } + if cancel != nil { + cancel() + } + synctest.Wait() + } +} + +func closingRejectionWrite(t *testing.T, c *errorDispositionConn) *diam.Message { + t.Helper() + select { + case wire := <-c.writes: + m, err := diam.ReadMessage(bytes.NewReader(wire), c.Dictionary()) + if err != nil { + t.Fatal(err) + } + return m + default: + t.Fatal("required adapter write missing") + return nil + } +} diff --git a/diam/sm/accepted_log_compat_test.go b/diam/sm/control_diagnostic_log_test.go similarity index 97% rename from diam/sm/accepted_log_compat_test.go rename to diam/sm/control_diagnostic_log_test.go index 5c9048d..9a360a6 100644 --- a/diam/sm/accepted_log_compat_test.go +++ b/diam/sm/control_diagnostic_log_test.go @@ -14,7 +14,7 @@ import ( // RFC 6733 §§5.4,5.5,6.3: these discarded controls retain the existing // StateMachine log cause and observer result, including a modified hook body. -func TestAcceptedInvalidControlLogCompatibilityWire(t *testing.T) { +func TestAcceptedInvalidControlLogEvidenceWire(t *testing.T) { for _, variant := range []string{"DWR-bound-host", "DPR-bound-host", "DWR-post-hook-missing-host"} { t.Run(variant, func(t *testing.T) { var mutated atomic.Bool diff --git a/diam/sm/rejection_construction_test.go b/diam/sm/rejection_construction_test.go new file mode 100644 index 0000000..1b52443 --- /dev/null +++ b/diam/sm/rejection_construction_test.go @@ -0,0 +1,237 @@ +package sm + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/gomaja/go-diameter/diam" + "github.com/gomaja/go-diameter/diam/avp" + "github.com/gomaja/go-diameter/diam/datatype" + "github.com/gomaja/go-diameter/diam/dict" + "github.com/gomaja/go-diameter/diam/internal/base" + "github.com/gomaja/go-diameter/diam/internal/logtest" + "github.com/gomaja/go-diameter/diam/internal/protocol" +) + +// A caller-supplied message dictionary can decode a valid base request while +// lacking the answer grammar, or forbid flags needed by the answer builder. +func rejectionConstructionDictionary(t *testing.T, forbidOriginM bool) *dict.Parser { + t.Helper() + p, err := dict.NewParser() + if err != nil { + t.Fatal(err) + } + originFlags := `must="M"` + if forbidOriginM { + originFlags = `must-not="M"` + } + err = p.Load(strings.NewReader(` + + + + + + +`)) + if err != nil { + t.Fatal(err) + } + return p +} + +func decodeRejectionRequest(t *testing.T, m *diam.Message, dictionary *dict.Parser) *diam.Message { + t.Helper() + wire, err := m.Serialize() + if err != nil { + t.Fatal(err) + } + decoded, err := diam.ReadMessage(bytes.NewReader(wire), dictionary) + if err != nil { + t.Fatal(err) + } + return decoded +} + +type rejectionConstructionPeer struct { + sm *StateMachine + conn diam.Conn + wire *finalDPATransport + logs *logtest.Recorder + events chan diam.ProtocolErrorEvent +} + +// Both adapters use their production reader and admission path. Synthetic +// channel transport makes the disconnect deadline independent of scheduling. +func newRejectionConstructionPeer(t *testing.T, role string) *rejectionConstructionPeer { + t.Helper() + dictionary := newSMTestDictionary(dict.Base()) + p := &rejectionConstructionPeer{ + wire: &finalDPATransport{reads: make(chan finalDPARead, 8), writes: make(chan *diam.Message, 8), closed: make(chan struct{}), eofRead: make(chan struct{})}, + logs: logtest.New(), events: make(chan diam.ProtocolErrorEvent, 8), + } + cfg := testMessageErrorSettings(dictionary) + cfg.AuthApplicationID = []*diam.AVP{diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))} + cfg.RejectUnknownMandatoryAVPs = true + cfg.OnProtocolError = func(e diam.ProtocolErrorEvent) { p.events <- e } + p.sm = mustNewStateMachine(t, cfg, dictionary) + if role == "accepted" { + server := &diam.Server{Handler: p.sm, Dict: dictionary, Logger: p.logs.Logger()} + var err error + p.conn, err = server.NewConn(p.wire) + if err != nil { + t.Fatal(err) + } + cleanup := p.sm.HandleAccept(p.conn) + t.Cleanup(cleanup) + cer := regressionCER(t, dictionary, 4) + setWireIdentity(t, cer, avp.OriginHost, "peer.example") + setWireIdentity(t, cer, avp.OriginRealm, "example") + cer.AddAVP(diam.NewAVP(avp.AuthApplicationID, avp.Mbit, 0, datatype.Unsigned32(4))) + p.receive(t, cer) + if answer := <-p.wire.writes; !testResultCode(answer, diam.Success) { + t.Fatalf("admission CEA = %v", answer) + } + } else { + client := &Client{Handler: p.sm, Dict: dictionary, Logger: p.logs.Logger(), RetransmitInterval: time.Hour} + dialed := make(chan protocolClientDialResult, 1) + go func() { + c, err := client.dial(func(activity *watchdogActivity) (diam.Conn, error) { + return client.server("", "synthetic", nil, activity).NewConn(p.wire) + }) + dialed <- protocolClientDialResult{conn: c, err: err} + }() + p.receive(t, protocolClientCEA(<-p.wire.writes, diam.Success)) + result := <-dialed + if result.err != nil { + t.Fatal(result.err) + } + p.conn = result.conn + } + t.Cleanup(func() { p.conn.Close() }) + synctest.Wait() + return p +} + +func (p *rejectionConstructionPeer) receive(t *testing.T, message *diam.Message) { + t.Helper() + wire, err := message.Serialize() + if err != nil { + t.Fatal(err) + } + p.wire.reads <- finalDPARead{data: wire} +} + +func TestClosingRejectionSkipsUnavailableAnswerGrammar(t *testing.T) { + for _, role := range []string{"accepted", "client"} { + for _, outcome := range []string{"answer", "deadline"} { + t.Run(role+"/"+outcome, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newRejectionConstructionPeer(t, role) + disconnected := make(chan error, 1) + go func() { disconnected <- p.sm.Disconnect(p.conn, DisconnectBusy, time.Hour) }() + dpr := <-p.wire.writes + synctest.Wait() + state := p.sm.acceptedCore(p.conn, nil, false) + before := state.snapshot().Timer(protocol.ClosingTimer) + request := diam.NewMessage(diam.DisconnectPeer, diam.RequestFlag, 0, 23, 24, p.conn.Dictionary()) + request.AddAVP(diam.NewAVP(avp.OriginHost, avp.Mbit, 0, datatype.DiameterIdentity("peer.example"))) + request.AddAVP(diam.NewAVP(avp.OriginRealm, avp.Mbit, 0, datatype.DiameterIdentity("example"))) + request.AddAVP(diam.NewAVP(avp.DisconnectCause, avp.Mbit, 0, datatype.Enumerated(0))) + request.AddAVP(diam.NewAVP(0xfedcab, avp.Mbit, 0, datatype.Unknown([]byte{1, 2, 3, 4}))) + request = decodeRejectionRequest(t, request, rejectionConstructionDictionary(t, false)) + if err := base.ValidateMessage(request); err != nil { + t.Fatalf("DPR must satisfy unconditional base grammar: %v", err) + } + if _, err := base.BuildErrorAnswer(request, baseSettings(p.sm.cfg), diam.AVPUnsupported, []*diam.AVP{request.AVP[len(request.AVP)-1]}, false); err == nil || !strings.Contains(err.Error(), "command code 282: dictionary entry not found") { + t.Fatalf("fixture must fail answer grammar lookup: %v", err) + } + p.sm.ServeDIAM(p.conn, request) + synctest.Wait() + event := <-p.events + assertProtocolEvent(t, event, p.conn, request.Header, diam.ProtocolUnsupportedAVP, diam.AVPUnsupported, false, diam.ProtocolReplyNone, 0) + after := state.snapshot() + if p.conn.Closed() || after.Phase() != protocol.Closing || after.Timer(protocol.ClosingTimer) != before || len(p.wire.writes) != 0 { + t.Fatalf("discard changed Closing: closed=%t timer=%+v writes=%d", p.conn.Closed(), after.Timer(protocol.ClosingTimer), len(p.wire.writes)) + } + select { + case err := <-disconnected: + t.Fatalf("discard completed Disconnect: %v", err) + default: + } + var want error + if outcome == "answer" { + p.receive(t, finalDPAnswer(dpr)) + } else { + time.Sleep(time.Until(before.Deadline)) + want = ErrDisconnectTimeout + } + synctest.Wait() + if err := <-disconnected; !errors.Is(err, want) || !p.conn.Closed() { + t.Fatalf("Disconnect = %v, want %v; closed=%t", err, want, p.conn.Closed()) + } + }) + }) + } + } +} + +func TestInvalidHeaderWarningAfterAnswerConstruction(t *testing.T) { + for _, role := range []string{"accepted", "client"} { + for _, failBuilder := range []bool{false, true} { + name := "written" + if failBuilder { + name = "construction-failed" + } + t.Run(role+"/"+name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + p := newRejectionConstructionPeer(t, role) + start := len(p.logs.Records()) + request := regressionDWR(t) + request.Header.CommandFlags |= diam.ProxiableFlag + dictionary := p.conn.Dictionary() + if failBuilder { + dictionary = rejectionConstructionDictionary(t, true) + } + request = decodeRejectionRequest(t, request, dictionary) + p.sm.ServeDIAM(p.conn, request) + synctest.Wait() + reply, code := diam.ProtocolReplyWritten, uint32(diam.InvalidHDRBits) + if failBuilder { + reply = diam.ProtocolReplyFailed + } + event := <-p.events + assertProtocolEvent(t, event, p.conn, request.Header, diam.ProtocolInvalidHeader, code, failBuilder, reply, code) + records := p.logs.Records()[start:] + wantRecords := 1 + if failBuilder { + wantRecords = 2 + } + if len(records) != wantRecords || records[0].Level != slog.LevelWarn || records[0].Message != "sm: invalid message header" { + t.Fatalf("header logs = %v", records) + } + value := logtest.Attr(records[0], "error").Any() + if failBuilder { + cause, ok := value.(*diam.ProtocolError) + if !ok || cause.Kind() != diam.ProtocolInvalidHeader || cause.ResultCode() != code || cause.Error() != diam.NewProtocolError(diam.ProtocolInvalidHeader, code).Error() { + t.Fatalf("header construction warning = %T %v", value, value) + } + if errors.Unwrap(cause) != diam.ProtocolInvalidHeader || errors.Unwrap(errors.Unwrap(cause)) != nil { + t.Fatalf("header construction warning changed unwrap chain: %v", cause) + } + } else { + cause, ok := value.(*diam.MessageError) + want := base.ValidateHeader(request) + if !ok || cause.Error() != want.Error() || errors.Unwrap(cause).Error() != errors.Unwrap(want).Error() { + t.Fatalf("written header warning = %T %v, want %v", value, value, want) + } + } + }) + }) + } + } +} diff --git a/diam/sm/smparser/cea.go b/diam/sm/smparser/cea.go index 8b4ae0f..7e45dc7 100644 --- a/diam/sm/smparser/cea.go +++ b/diam/sm/smparser/cea.go @@ -10,7 +10,7 @@ import ( "github.com/gomaja/go-diameter/diam" "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" - "github.com/gomaja/go-diameter/diam/sm/internal/parserbridge" + "github.com/gomaja/go-diameter/diam/internal/parserbridge" ) func init() { parserbridge.FromValidatedCEA = fromValidatedCEA } diff --git a/diam/sm/smparser/cer.go b/diam/sm/smparser/cer.go index 8254156..3543c0c 100644 --- a/diam/sm/smparser/cer.go +++ b/diam/sm/smparser/cer.go @@ -11,7 +11,7 @@ import ( "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/dict" "github.com/gomaja/go-diameter/diam/internal/base" - "github.com/gomaja/go-diameter/diam/sm/internal/parserbridge" + "github.com/gomaja/go-diameter/diam/internal/parserbridge" ) func init() { diff --git a/diam/sm/smparser/cer_bridge_test.go b/diam/sm/smparser/cer_bridge_test.go index 2941a41..919c992 100644 --- a/diam/sm/smparser/cer_bridge_test.go +++ b/diam/sm/smparser/cer_bridge_test.go @@ -9,7 +9,7 @@ import ( "github.com/gomaja/go-diameter/diam/avp" "github.com/gomaja/go-diameter/diam/datatype" "github.com/gomaja/go-diameter/diam/internal/base" - "github.com/gomaja/go-diameter/diam/sm/internal/parserbridge" + "github.com/gomaja/go-diameter/diam/internal/parserbridge" ) // Package sm reaches the conversion only through parserbridge.