From c54bfe23be4d0ae334ac5373b45ebdfcbdfbd932 Mon Sep 17 00:00:00 2001 From: calebtt Date: Fri, 25 Sep 2026 22:28:04 -0500 Subject: [PATCH] Answer the final transfer NOTIFY before hanging up the original leg With #18, BlindTransferAsync hung up as soon as the REFER was accepted. Asterisk reports the transfer's progress with in-dialog NOTIFYs (Event: refer, message/sipfrag) sent right after the 202, the last one with "SIP/2.0 200 OK" and Subscription-State: terminated. Arriving after the BYE, they had no dialog to answer them, and the PBX retransmitted each for ~32 s (seen live: 3 NOTIFYs, 6 retransmissions). BlindTransferAsync now subscribes to OnTransferNotify before sending the REFER and, once it is accepted, waits for a sipfrag status of 200 or more (answered in the dialog by SIPUserAgent) for up to 5 s, then hangs up. A transferee that sends no NOTIFY costs the full 5 s before the BYE. New test: the transferee sends the final NOTIFY after accepting; it gets 200, the BYE follows it, and BlindTransferAsync returns well within the cap. On master the NOTIFY gets no response. Closes #24 Co-Authored-By: Claude Opus 5.5 --- Network/SipClient.cs | 33 ++++++++++++ tests/SipBotLib.Tests/BlindTransferTests.cs | 58 +++++++++++++++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Network/SipClient.cs b/Network/SipClient.cs index bae845a..765ff7b 100644 --- a/Network/SipClient.cs +++ b/Network/SipClient.cs @@ -35,6 +35,12 @@ public class SipClient : IDisposable private const int DefaultRegistrationExpirySeconds = 60; private const int BlindTransferTimeoutSeconds = 10; + /// + /// How long waits, after the REFER is accepted, for the final + /// transfer-progress NOTIFY before it hangs up. + /// + internal static readonly TimeSpan TransferNotifyWait = TimeSpan.FromSeconds(5); + /// How long waits for the registrar to confirm the unregister. internal static readonly TimeSpan UnregisterTimeout = TimeSpan.FromSeconds(3); @@ -654,6 +660,22 @@ public async Task BlindTransferAsync( return false; } + // The transferee (Asterisk here) reports progress with in-dialog NOTIFYs carrying a sipfrag + // ("SIP/2.0 100 Trying" ... "SIP/2.0 200 OK"). SIPUserAgent answers them while the dialog + // exists; hanging up before they arrive leaves them unanswered and the PBX retransmits them. + // Subscribe before sending the REFER: they can arrive right behind the 202. + var finalNotify = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnTransferNotify(string sipfrag) + { + string statusLine = sipfrag.Split('\n')[0].Trim(); + if (statusLine.StartsWith("SIP/2.0 ") && statusLine.Length >= 11 + && int.TryParse(statusLine.AsSpan(8, 3), out int code) && code >= 200) + { + finalNotify.TrySetResult(statusLine); + } + } + _userAgent.OnTransferNotify += OnTransferNotify; + try { sipUri = SipUriNormalizer.Normalize(sipUri, _sipServer); @@ -679,6 +701,13 @@ public async Task BlindTransferAsync( StatusMessage?.Invoke(this, $"Blind transfer to {sipUri} succeeded."); Log.Information($"Blind transfer to {sipUri} succeeded"); + // Let the final progress NOTIFY arrive (and be answered in the dialog) first. Not + // every transferee sends one, so the wait is bounded. + var first = await Task.WhenAny(finalNotify.Task, Task.Delay(TransferNotifyWait)).ConfigureAwait(false); + Log.Information("Transfer progress: {Outcome}", first == finalNotify.Task + ? finalNotify.Task.Result + : $"no final NOTIFY within {TransferNotifyWait.TotalSeconds:0} s"); + // The REFER was accepted, so the transferee now belongs to the transfer target. // PBXs such as Asterisk take this leg out of the bridge but leave it up; end it // here (BYE), which raises CallEnded as for any hangup. @@ -710,6 +739,10 @@ public async Task BlindTransferAsync( Log.Error(ex, msg); return false; } + finally + { + _userAgent.OnTransferNotify -= OnTransferNotify; + } } /// diff --git a/tests/SipBotLib.Tests/BlindTransferTests.cs b/tests/SipBotLib.Tests/BlindTransferTests.cs index 2fc6c08..2734f2d 100644 --- a/tests/SipBotLib.Tests/BlindTransferTests.cs +++ b/tests/SipBotLib.Tests/BlindTransferTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using SIPSorcery.Media; using SIPSorcery.SIP; @@ -33,6 +34,11 @@ private sealed class SilentEndPoint : BaseAudioEndPoint private readonly TaskCompletionSource _byeAtCallee = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _callerEnded = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _acceptTransfer = true; + private bool _sendFinalNotify; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + private readonly TaskCompletionSource<(SIPResponseStatusCodesEnum Status, TimeSpan At)> _notifyAnswered = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private TimeSpan _byeAt; public BlindTransferTests() { @@ -56,11 +62,16 @@ public BlindTransferTests() // so OnCallHungup is not a reliable signal here. _calleeTransport.SIPTransportRequestReceived += (_, _, req) => { - if (req.Method == SIPMethodsEnum.BYE) - _byeAtCallee.TrySetResult(true); + if (req.Method == SIPMethodsEnum.BYE && _byeAtCallee.TrySetResult(true)) + _byeAt = _clock.Elapsed; return Task.CompletedTask; }; - _callee.OnTransferRequested += (_, _) => _acceptTransfer; + _callee.OnTransferRequested += (_, _) => + { + if (_acceptTransfer && _sendFinalNotify) + _ = SendFinalReferNotifyAsync(); + return _acceptTransfer; + }; var config = new SipConfig { Server = "127.0.0.1", Username = "101", Password = "x" }; _caller = new SipClient(_callerTransport, config, enableAutoReconnection: false, enableHealthMonitoring: false); @@ -75,6 +86,27 @@ public void Dispose() _calleeTransport.Shutdown(); } + /// + /// What Asterisk does after accepting a REFER: an in-dialog NOTIFY (Event: refer) whose + /// sipfrag reports the outcome. SIPSorcery's own transferee does not send one. + /// + private async Task SendFinalReferNotifyAsync() + { + await Task.Delay(50); // let the 202 go out first + var notify = _callee.Dialogue.GetInDialogRequest(SIPMethodsEnum.NOTIFY); + notify.Header.Event = "refer"; + notify.Header.SubscriptionState = "terminated;reason=noresource"; + notify.Header.ContentType = "message/sipfrag;version=2.0"; + notify.Body = "SIP/2.0 200 OK\r\n"; + var tx = new SIPNonInviteTransaction(_calleeTransport, notify, null); + tx.NonInviteTransactionFinalResponseReceived += (_, _, _, resp) => + { + _notifyAnswered.TrySetResult((resp.Status, _clock.Elapsed)); + return Task.FromResult(System.Net.Sockets.SocketError.Success); + }; + tx.SendRequest(); + } + private static async Task Within(Task task, TimeSpan timeout) => await Task.WhenAny(task, Task.Delay(timeout)) == task; @@ -99,6 +131,26 @@ public async Task Accepted_transfer_hangs_up_the_original_leg() Assert.True(await Within(_byeAtCallee.Task, TimeSpan.FromSeconds(5)), "the transferee never received a BYE"); } + [Fact] + public async Task Final_progress_NOTIFY_is_answered_before_the_BYE() + { + _sendFinalNotify = true; + await ConnectAsync(); + + var elapsed = Stopwatch.StartNew(); + bool transferred = await _caller.BlindTransferAsync(TransferTarget, TimeSpan.FromSeconds(5)); + elapsed.Stop(); + + Assert.True(transferred); + Assert.True(await Within(_notifyAnswered.Task, TimeSpan.FromSeconds(5)), "the NOTIFY got no final response"); + var (status, answeredAt) = _notifyAnswered.Task.Result; + Assert.Equal(SIPResponseStatusCodesEnum.Ok, status); + Assert.True(await Within(_byeAtCallee.Task, TimeSpan.FromSeconds(5)), "the transferee never received a BYE"); + Assert.True(_byeAt >= answeredAt, $"BYE at {_byeAt} came before the NOTIFY was answered at {answeredAt}"); + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(3), $"BlindTransferAsync took {elapsed.Elapsed}; it should not wait out the cap"); + Assert.True(_callerEnded.Task.IsCompleted); + } + [Fact] public async Task Declined_transfer_leaves_the_call_up() {