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()
{