Skip to content

Commit 186d7ec

Browse files
authored
fix: handle unmatched 487 response on delayed call cancellation (#1542)
* fix: handle unmatched 487 response on delayed call cancellation - Add 'CancelledAt' property to SIPTransaction to track cancellation time. - Modify SIPTransactionEngine to use 'CancelledAt' instead of 'Created' when expiring transactions in the Cancelled state. This resolves an issue where cancelling an INVITE after 32 seconds resulted in the transaction being immediately garbage collected, leaving the final 487 response orphaned and triggering a generic SIPTransportResponseReceived event instead of closing gracefully. Assisted-by: GPT-5.3-Codex * fix: encapsulate CancelledAt property and update unit tests for transaction cancellation Assisted-by: GPT-5.4
1 parent a036f5d commit 186d7ec

File tree

3 files changed

+71
-0
lines changed

3 files changed

+71
-0
lines changed

src/SIPSorcery/core/SIPTransactions/SIPTransaction.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ public string TransactionId
138138
/// </summary>
139139
public DateTime TimedOutAt;
140140

141+
/// <summary>
142+
/// For cancelled INVITEs this is the time they entered the cancelled state and is used to
143+
/// calculate expiry after T6. If unset, Created is used as a fallback by the transaction engine.
144+
/// </summary>
145+
public DateTime CancelledAt { get; private set; }
146+
141147
protected string m_branchId;
142148
public string BranchId
143149
{
@@ -342,6 +348,11 @@ protected void UpdateTransactionState(SIPTransactionStatesEnum transactionState)
342348
transactionState == SIPTransactionStatesEnum.Terminated ||
343349
transactionState == SIPTransactionStatesEnum.Cancelled)
344350
{
351+
if (transactionState == SIPTransactionStatesEnum.Cancelled && CancelledAt == DateTime.MinValue)
352+
{
353+
CancelledAt = DateTime.Now;
354+
}
355+
345356
DeliveryPending = false;
346357
}
347358
else if (transactionState == SIPTransactionStatesEnum.Completed)

src/SIPSorcery/core/SIPTransactions/SIPTransactionEngine.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,15 @@ private void RemoveExpiredTransactions()
689689
expiredTransactionIds.Add(transaction.TransactionId);
690690
}
691691
}
692+
else if (transaction.TransactionState == SIPTransactionStatesEnum.Cancelled)
693+
{
694+
var cancelledAt = (transaction.CancelledAt == DateTime.MinValue) ? transaction.Created : transaction.CancelledAt;
695+
696+
if (now.Subtract(cancelledAt).TotalMilliseconds >= m_t6)
697+
{
698+
expiredTransactionIds.Add(transaction.TransactionId);
699+
}
700+
}
692701
else if (now.Subtract(transaction.Created).TotalMilliseconds >= m_t6)
693702
{
694703
//logger.LogDebug("INVITE transaction (" + transaction.TransactionId + ") " + transaction.TransactionRequestURI.ToString() + " in " + transaction.TransactionState + " has been alive for " + DateTime.Now.Subtract(transaction.Created).TotalSeconds.ToString("0") + ".");

test/unit/core/SIPTransactions/SIPTransactionEngineUnitTest.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using System.Linq;
1515
using System.Net;
1616
using System.Net.Sockets;
17+
using System.Reflection;
1718
using System.Threading.Tasks;
1819
using Microsoft.Extensions.Logging;
1920
using Xunit;
@@ -222,6 +223,56 @@ public void AckRecognitionIIUnitTest()
222223
Assert.True(matchingTransaction.TransactionId == serverTransaction.TransactionId, "ACK transaction did not match INVITE transaction.");
223224
}
224225

226+
[Fact]
227+
public void CancelledInviteWithoutCancelledAt_ExpiresUsingCreatedFallback()
228+
{
229+
logger.LogDebug("--> {MethodName}", System.Reflection.MethodBase.GetCurrentMethod().Name);
230+
logger.BeginScope(System.Reflection.MethodBase.GetCurrentMethod().Name);
231+
232+
SIPTransport sipTransport = new SIPTransport();
233+
SIPTransactionEngine engine = sipTransport.m_transactionEngine;
234+
235+
SIPRequest inviteRequest = GetDummyINVITERequest(SIPURI.ParseSIPURI("sip:dummy@127.0.0.1:12014"));
236+
var tx = new UACInviteTransaction(sipTransport, inviteRequest, null);
237+
engine.AddTransaction(tx);
238+
239+
tx.Created = DateTime.Now.AddMilliseconds(-(SIPTimings.T6 + 1000));
240+
241+
var stateField = typeof(SIPTransaction).GetField("m_transactionState", BindingFlags.Instance | BindingFlags.NonPublic);
242+
stateField.SetValue(tx, SIPTransactionStatesEnum.Cancelled);
243+
244+
var removeExpiredMethod = typeof(SIPTransactionEngine).GetMethod("RemoveExpiredTransactions", BindingFlags.Instance | BindingFlags.NonPublic);
245+
removeExpiredMethod.Invoke(engine, null);
246+
247+
Assert.Null(engine.GetTransaction(inviteRequest));
248+
249+
sipTransport.Shutdown();
250+
}
251+
252+
[Fact]
253+
public void CancelledInviteWithRecentCancelledAt_IsNotExpired()
254+
{
255+
logger.LogDebug("--> {MethodName}", System.Reflection.MethodBase.GetCurrentMethod().Name);
256+
logger.BeginScope(System.Reflection.MethodBase.GetCurrentMethod().Name);
257+
258+
SIPTransport sipTransport = new SIPTransport();
259+
SIPTransactionEngine engine = sipTransport.m_transactionEngine;
260+
261+
SIPRequest inviteRequest = GetDummyINVITERequest(SIPURI.ParseSIPURI("sip:dummy@127.0.0.1:12014"));
262+
var tx = new UACInviteTransaction(sipTransport, inviteRequest, null);
263+
engine.AddTransaction(tx);
264+
265+
tx.Created = DateTime.Now.AddMilliseconds(-(SIPTimings.T6 * 2));
266+
tx.CancelCall();
267+
268+
var removeExpiredMethod = typeof(SIPTransactionEngine).GetMethod("RemoveExpiredTransactions", BindingFlags.Instance | BindingFlags.NonPublic);
269+
removeExpiredMethod.Invoke(engine, null);
270+
271+
Assert.NotNull(engine.GetTransaction(inviteRequest));
272+
273+
sipTransport.Shutdown();
274+
}
275+
225276
private SIPRequest GetDummyINVITERequest(SIPURI dummyURI)
226277
{
227278
string dummyFrom = "<sip:unittest@mysipswitch.com>";

0 commit comments

Comments
 (0)