Skip to content

feat: implement RFC 4028 session timer compliance - #51

Open
thorsager wants to merge 8 commits into
mainfrom
rfc4028_timer
Open

feat: implement RFC 4028 session timer compliance#51
thorsager wants to merge 8 commits into
mainfrom
rfc4028_timer

Conversation

@thorsager

@thorsager thorsager commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Implements full RFC 4028 session timer compliance, replacing the previous fire-and-forget timer with a proper session timer system that negotiates parameters with peers, sends re-INVITEs to refresh sessions, and handles 422 responses.

Changes

Session Timer Infrastructure (internal/b2bua/sessiontimer.go)

  • SessionTimer struct tracking interval, refresher role, and expiry per dialog leg
  • Parsing helpers: ParseSessionExpires, ParseMinSE, HasTimerSupport
  • StartSessionTimer / StopSessionTimer / ResetSessionTimer for timer lifecycle
  • Refresher-side loop sends re-INVITE at half the session interval, awaiting each refresh's outcome inline so only one refresh transaction is ever outstanding per dialog (RFC 3261 §12.2.1)
  • Non-refresher-side goroutine sends BYE if no refresh received (safety margin: min(32s, interval/3))

In-Dialog re-INVITE Handling (internal/b2bua/handler.go)

  • Dialog-aware routing in HandleInvite detects re-INVITEs by checking To tag + Call-ID in store
  • handleReInvite forwards re-INVITEs across legs with response relay via reInviteResponseLoop
  • Session timers are reset only when a forwarded re-INVITE is confirmed with a 200 OK (RFC 4028 §7.2): a rejected refresh does not extend the session

Session Timer Header Negotiation

  • Outbound INVITEs (trunk and internal) include Supported: timer, Min-SE, and Session-Expires
  • 200 OK responses include Session-Expires, Supported: timer, and Require: timer
  • Inbound INVITEs with Session-Expires below Min-SE are rejected with 422
  • Trunk 422 responses trigger a retry with updated Min-SE and higher Session-Expires, on a fresh transaction; renegotiation is bounded (requires progress from the peer's Min-SE, capped retries) so a misbehaving peer cannot cause a retry loop
  • Non-trunk 422 responses are relayed to the caller with the peer's Min-SE; retry there is not yet implemented and remains open on Implement Full RFC 4028 Session Timer Compliance #39

Unified BYE Handling

  • sendBye and sendByeBothLegs replace ad-hoc BYE logic with shared helpers
  • Timer expiry, refresh failures (408/481), and other teardown scenarios use unified path

Configuration

  • --session-timer flag (default 1800s) — global default Session-Expires; 0 fully disables session-timer negotiation (no offers, no 422 handling, no Require: timer)
  • --min-se flag (default 90s) — minimum acceptable Session-Expires
  • Per-trunk SessionExpiresSec continues to work as override

References

Testing

  • 30+ new unit tests covering parsing/negotiation, refresh serialization, 422 retry and guard paths, and re-INVITE relay
  • All existing unit and integration tests pass; race detector clean

Implement SIP session timers per RFC 4028 for both internal B2BUA
calls and trunk calls, with configurable Session-Expires and Min-SE
values exposed as CLI flags (-session-timer, -min-se).

Session timer negotiation:
- Add Supported: timer, Min-SE, and Session-Expires headers to
  outbound INVITEs on both internal and trunk legs
- Negotiate Session-Expires and refresher (uac/uas) in 200 OK
  responses to Alice when the inbound INVITE supports timer
- Reject inbound INVITEs with Session-Expires below Min-SE using
  422 Session Interval Too Small (new status code constant)
- Handle 422 responses from Bob/trunk peers by raising Min-SE and
  retrying the INVITE with an updated Session-Expires (trunk path
  retries in place; internal path forwards the 422 to Alice)

Timer lifecycle (new internal/b2bua/sessiontimer.go):
- SessionTimer struct tracks interval, Min-SE, refresher role, and
  expiry per call leg (Alice and Bob independently)
- Refresher side sends a re-INVITE at half the interval; on refresh
  failure or rejection (408/481) the call is torn down
- Non-refresher side tears down the call if no refresh arrives
  before a safety margin of min(32s, interval/3) prior to expiry
- Helpers for parsing and formatting Session-Expires/Min-SE headers
  and detecting timer support in the Supported header

In-dialog re-INVITE handling:
- Detect re-INVITEs by To tag and Call-ID in HandleInvite and route
  them to a new dialog handler
- Forward re-INVITEs (with SDP if present) to the opposite leg and
  relay provisional, 200, and error responses back
- Reset the session timer for both legs on re-INVITE and its 200 OK

Teardown:
- Replace the ad-hoc trunkSessionTimer goroutine with unified
  sendBye/sendByeBothLegs helpers that send BYE with a Reason header,
  stop timers and media, remove the call from the store, and release
  trunk channels

Add unit tests for Session-Expires/Min-SE parsing, timer support
detection, and header formatting helpers.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds RFC 4028 session timer support to the B2BUA by introducing per-leg session timer state, negotiating Session-Expires/Min-SE with peers (including 422 handling), and adding in-dialog re-INVITE forwarding and timer-driven refresh/teardown behavior.

Changes:

  • Added SessionTimer infrastructure (parsing/formatting helpers + refresher/non-refresher timer loops + refresh re-INVITE sending).
  • Added in-dialog re-INVITE detection/forwarding and unified BYE teardown helpers.
  • Added configuration flags for default session timer interval and minimum acceptable session interval.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
proto/sip_constants.go Adds RFC 4028 status code constant (422).
internal/b2bua/sessiontimer.go New session timer implementation (parse/format helpers, timer loops, refresh sending).
internal/b2bua/sessiontimer_test.go Unit tests for session timer parsing/formatting helpers.
internal/b2bua/handler.go Integrates session timers into INVITE flows, adds re-INVITE forwarding, 422 handling, and unified BYE teardown.
internal/b2bua/call.go Stores per-leg session timer state on the Call object.
cmd/trecsd/main.go Adds CLI flags for session timer defaults (--session-timer, --min-se).
Suppressed comments (1)

internal/b2bua/handler.go:1678

  • In b2buaResponseLoop, the 422 handler logs that it is "retrying" but then explicitly forwards the 422 to Alice because there is no retry mechanism. This contradicts the intended RFC 4028 behavior and the surrounding log message, and it also mutates h.minSE globally based on one peer response.
			// Handle 422 Session Interval Too Small (RFC 4028 §5).
			if sc == proto.SIPStatusSessionIntervalTooSmall {
				h.cancelPRACK(cc.callID)
				peerMinSE := ParseMinSE(resp.Headers.GetFirst("Min-SE"))
				if peerMinSE > h.minSE {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/b2bua/sessiontimer.go Outdated
Comment thread internal/b2bua/sessiontimer.go
Comment thread internal/b2bua/handler.go Outdated
Comment thread internal/b2bua/handler.go
Comment thread internal/b2bua/handler.go Outdated
Comment thread cmd/trecsd/main.go
Comment thread internal/b2bua/handler.go
Comment thread internal/b2bua/handler.go
The B2BUA session timer logic had several bugs around RFC 4028
negotiation. Min-SE was mutated on the handler-wide default instead of
per Call-ID, the refresher role for non-timer peers was miscalculated,
and 422 retries / re-INVITEs / session refreshes used unregistered Via
branches, so responses could not be routed back to their UAC
transactions.

This change scopes Min-SE negotiation to each call, negotiates the Bob
leg timer from the peer 200 OK correctly, propagates session-timer
headers across legs, and creates UAC transactions before building
requests so their branches are registered in the UACManager. CANCEL now
targets the live retry transaction, and BYE uses the dialog's
incremented local CSeq.

Also fixes flag validation (Min-SE >= 90s per RFC 4028 §5), a UAS
refresher check that would never fire, and adds tests covering re-INVITE
branch matching, session header forwarding, and session timer
negotiation.
Add a deadline to waitForRefreshResponse so the server tears down
the call if the peer does not accept the re-INVITE within the
session interval.  The trunk test ghost peer now stops answering
re-INVITEs after the initial INVITE so the teardown path is
exercised.  Update the wait time accordingly.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are correctness issues in session-timer parsing/negotiation and timer lifecycle control (including an inability to disable timers via the documented flag) that can break RFC 4028 behavior in production.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 11/11 changed files
  • Comments generated: 6
  • Review effort level: Lite

Comment thread internal/b2bua/handler.go Outdated
Comment thread internal/b2bua/handler.go
Comment thread internal/b2bua/sessiontimer.go
Comment thread internal/b2bua/sessiontimer.go
Comment thread internal/b2bua/sessiontimer.go Outdated
Comment thread internal/b2bua/handler.go Outdated
- honor --session-timer=0 as a full opt-out of timer negotiation (422
  checks, offers, and 200 OK handling are skipped when disabled)
- ParseSessionExpires reports absent/unparseable headers as 0 instead
  of silently forcing DefaultSessionExpires, so configured defaults and
  UAS refresher are preserved across both legs
- parse refresher param case-insensitively and accept generic Min-SE
  parameters (e.g. "90;foo=bar")
- keep session timers attached to their parent context on reset, stop
  both legs' timers on BYE, guard sendByeBothLegs against already
  removed calls, and cancel refresh transactions on context teardown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The re-INVITE relay path currently alters/strips RFC 4028 negotiation headers (and advertises Supported: timer unconditionally) and the PR description overstates 422 retry coverage, which can break session-timer compliance in real dialog refresh flows.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

internal/b2bua/handler.go:1772

  • PR description says 422 responses trigger an automatic retry with updated Min-SE / Session-Expires for outbound INVITEs, but the non-trunk B2BUA path explicitly forwards 422 to Alice and returns (no retry). Either implement the retry here (similar to trunkResponseLoop) or update the PR description to scope the retry behavior to trunks only.

internal/b2bua/handler.go:905

  • reInviteResponseLoop forwards error responses using proto.NewResponse(origReq, ...) but doesn’t copy required headers from the upstream response. In particular, a 422 response must carry Min-SE (RFC 4028 §5); dropping it prevents the originating leg from retrying with an acceptable interval.
			if sc >= 300 {
				log.Info("B2BUA: re-INVITE error response, forwarding",
					"statusCode", sc, "fromAlice", isFromAlice)
				errReason := resp.Status()
				if idx := strings.Index(errReason, " "); idx != -1 {
					errReason = errReason[idx+1:]
				}
				origTx.Respond(proto.NewResponse(origReq, sc, errReason))
				return
  • Files reviewed: 11/11 changed files
  • Comments generated: 3
  • Review effort level: Lite

Comment thread internal/b2bua/handler.go
Comment thread internal/b2bua/handler.go
Comment thread internal/b2bua/sessiontimer.go Outdated
- advertise Supported: timer on forwarded re-INVITEs only when session
  timers are actually in play for the dialog (request engages them or a
  leg-level timer was negotiated)
- relay Session-Expires/Min-SE and timer Require/Supported option tags
  from the peer response back to the re-INVITE originator, so a 422
  keeps its mandatory Min-SE (RFC 4028 §5)
- replace time.After with a stoppable timer in waitForRefreshResponse
  to avoid arming a full-interval timer on every early return

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

There is at least one confirmed resource-leak bug in the 422 handling path (and an in-dialog refresh concurrency risk) that should be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/b2bua/handler.go:1787

  • On a 422 (Session Interval Too Small) from Bob, this path forwards the 422 to Alice and returns without closing the allocated RTP conns. Since there is explicitly no retry implemented here, the call setup is failing and these RTP ports will leak until process exit.

Either implement the RFC 4028 retry here (like trunkResponseLoop does) or perform the same cleanup as other failure paths (close both RTP conns) before responding.

  • Files reviewed: 11/11 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread internal/b2bua/sessiontimer.go Outdated
The refresher loop launched waitForRefreshResponse in a goroutine and
immediately re-armed its timer, so a silent peer caused a second
in-dialog re-INVITE at the next half-interval while the first was still
outstanding (RFC 3261 §12.2.1 glare, 491 risk).

The loop now awaits each refresh's outcome inline: accepted (timer
reset, new generation owns the schedule) and terminated (teardown or
context cancel) stop the loop, while non-fatal rejections such as 491
retry at the next half-interval point. sendSessionRefresh returns the
UAC transaction, and tests cover the no-overlap guarantee, resumption
after acceptance, and stop on context cancel.
The overlap test used a 1s session interval, leaving only ~400ms
between feeding the refresh's 200 OK and the first transaction's
in-call interval deadline; a stall on loaded CI could tear the call
down mid-test. Double the interval, express the in-flight and
post-cancel checks as interval-relative sleeps, and extend the
poll deadline, keeping every window seconds of slack. Behavior
asserted (one branch per refresh, resume after acceptance, stop on
cancel) is unchanged.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The current 422 retry logic can misbehave when Min-SE is missing/invalid, and session-timer reset semantics for forwarded re-INVITEs can extend sessions even when refresh ultimately fails.

Review details

Suppressed comments (4)

Previously missed (4) — in code that hasn't changed since the last review.

internal/b2bua/handler.go:828

  • handleReInvite resets the originating leg’s session timer immediately after sending the forwarded re-INVITE. If the forwarded re-INVITE is ultimately rejected (>=300) and that error is relayed back, the session wasn’t actually refreshed, but the timer was still extended on the originating leg.
    internal/b2bua/handler.go:1341
  • The 422 (Session Interval Too Small) retry path assumes the peer always includes a valid Min-SE. If the peer omits Min-SE or sends an unparsable value, peerMinSE becomes 0 and the code will still retry (potentially repeatedly) without ever raising the interval, which can loop until the response context is canceled.
    internal/b2bua/handler.go:1605
  • negotiateAliceSessionTimer only engages timers when the request has Supported: timer. Elsewhere (HandleInvite) Min-SE enforcement is triggered purely by the presence of Session-Expires, so an inbound INVITE with Session-Expires but without Supported: timer can be rejected for being too small, yet (if acceptable) will later be treated as having no timer support and get no Session-Expires/Require in the 200 OK. Consider treating Session-Expires / Min-SE / Require: timer as engaging timers too.
    internal/b2bua/handler.go:1777
  • This 422 handling block explicitly notes that a retry is "not yet implemented" and just forwards the 422 to Alice. That contradicts the PR description’s claim that 422 responses trigger an automatic retry with updated Min-SE/Session-Expires (at least for the internal/B2BUA (non-trunk) call path handled by this response loop).
  • Files reviewed: 11/11 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Follow-up to the Copilot review of the session-timer PR:

- Reset both legs' session timers only when a forwarded re-INVITE is
  confirmed with a 200 OK (RFC 4028 §7.2 refresh semantics). A re-INVITE
  that is ultimately rejected no longer extends the session on the
  originating leg.
- Guard the trunk 422 renegotiation loop: a 422 whose Min-SE is missing,
  unparsable, or already satisfied by the current offer is not retryable
  and now fails the call with 488 instead of resending the identical
  INVITE; consecutive renegotiations are capped (max422Retries) so a
  peer that keeps raising Min-SE cannot spin.
- Release the RTP connections on the non-trunk 422 relay path (no retry
  mechanism there, so the setup is failing; previously the ports leaked
  until process exit).
- Centralize timer engagement in requestEngagesTimers (Supported:
  timer, Require: timer, Session-Expires, or Min-SE) and use it in both
  the inbound 422 gate's counterpart negotiation and re-INVITE
  forwarding, so a peer offering Session-Expires without Supported:
  timer is treated consistently (previously it could be 422-enforced on
  one path and silently ignored on another).

Tests: re-INVITE timer reset fires only on confirmed 200; trunk 422
rig refactor with unretryable-Min-SE and retry-cap cases; RTP conns
asserted closed on the non-trunk 422 path; engagement cases in
negotiateAliceSessionTimer.
@github-actions

Copy link
Copy Markdown

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/thorsager/trecs/cmd/trecsd 0.00% (ø)
github.com/thorsager/trecs/integrationtest/trunk 0.00% (ø)
github.com/thorsager/trecs/internal/b2bua 25.61% (+21.81%) 🌟
github.com/thorsager/trecs/internal/sip 81.40% (+0.32%) 👍
github.com/thorsager/trecs/proto 89.02% (ø)

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/thorsager/trecs/cmd/trecsd/main.go 0.00% (ø) 120 (+11) 0 120 (+11)
github.com/thorsager/trecs/integrationtest/trunk/trunk_helpers.go 0.00% (ø) 0 0 0
github.com/thorsager/trecs/internal/b2bua/call.go 50.00% (+18.97%) 38 (+9) 19 (+10) 19 (-1) 🎉
github.com/thorsager/trecs/internal/b2bua/handler.go 17.52% (+14.51%) 1244 (+248) 218 (+188) 1026 (+60) 🎉
github.com/thorsager/trecs/internal/b2bua/sessiontimer.go 74.73% (+74.73%) 186 (+186) 139 (+139) 47 (+47) 🌟
github.com/thorsager/trecs/internal/sip/dialog.go 20.00% (+20.00%) 25 5 (+5) 20 (-5) 🎉
github.com/thorsager/trecs/proto/sip_constants.go 0.00% (ø) 0 0 0

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/thorsager/trecs/integrationtest/trunk/trunk_test.go
  • github.com/thorsager/trecs/internal/b2bua/handler_test.go
  • github.com/thorsager/trecs/internal/b2bua/sessiontimer_test.go
  • github.com/thorsager/trecs/internal/sip/dialog_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request minor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Session Timer Support (RFC 4028)

2 participants