Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Network/SipClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
private const int DefaultRegistrationExpirySeconds = 60;
private const int BlindTransferTimeoutSeconds = 10;

/// <summary>
/// How long <see cref="BlindTransferAsync"/> waits, after the REFER is accepted, for the final
/// transfer-progress NOTIFY before it hangs up.
/// </summary>
internal static readonly TimeSpan TransferNotifyWait = TimeSpan.FromSeconds(5);

/// <summary>How long <see cref="Shutdown"/> waits for the registrar to confirm the unregister.</summary>
internal static readonly TimeSpan UnregisterTimeout = TimeSpan.FromSeconds(3);

Expand Down Expand Up @@ -76,8 +82,8 @@
public event Action<SipClient>? CallAnswer;
public event Action<SipClient>? CallEnded;
public event Action<SipClient, string>? StatusMessage;
public event Action<SipClient>? RemotePutOnHold;

Check warning on line 85 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used

Check warning on line 85 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemotePutOnHold' is never used
public event Action<SipClient>? RemoteTookOffHold;

Check warning on line 86 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used

Check warning on line 86 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

The event 'SipClient.RemoteTookOffHold' is never used
public event Action<SipClient, Exception>? ErrorOccurred;
public event Action<SipClient>? RegistrationStatusChanged;
public event Action<SipClient, TimeSpan>? CallDurationUpdated;
Expand Down Expand Up @@ -139,7 +145,7 @@
public string Username => _sipUsername;
public string Server => _sipServer;

public SipClient(

Check warning on line 148 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 148 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 148 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_registrationAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 148 in Network/SipClient.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable field '_userAgent' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
SIPTransport sipTransport,
SipConfig sipSettings,
int registrationExpirySeconds = DefaultRegistrationExpirySeconds,
Expand Down Expand Up @@ -654,6 +660,22 @@
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<string>(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);
Expand All @@ -679,6 +701,13 @@
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.
Expand Down Expand Up @@ -710,6 +739,10 @@
Log.Error(ex, msg);
return false;
}
finally
{
_userAgent.OnTransferNotify -= OnTransferNotify;
}
}

/// <summary>
Expand Down
58 changes: 55 additions & 3 deletions tests/SipBotLib.Tests/BlindTransferTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Net;
using SIPSorcery.Media;
using SIPSorcery.SIP;
Expand Down Expand Up @@ -33,6 +34,11 @@
private readonly TaskCompletionSource<bool> _byeAtCallee = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> _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()
{
Expand All @@ -56,11 +62,16 @@
// 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);
Expand All @@ -75,6 +86,27 @@
_calleeTransport.Shutdown();
}

/// <summary>
/// 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.
/// </summary>
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<bool> Within(Task task, TimeSpan timeout) =>
await Task.WhenAny(task, Task.Delay(timeout)) == task;

Expand All @@ -99,6 +131,26 @@
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;

Check warning on line 146 in tests/SipBotLib.Tests/BlindTransferTests.cs

View workflow job for this annotation

GitHub Actions / build

Test methods should not use blocking task operations, as they can cause deadlocks. Use an async test method and await instead. (https://xunit.net/xunit.analyzers/rules/xUnit1031)
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()
{
Expand Down
Loading