engine: keep the reg-event subscription alive and act on a terminated binding
The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
This commit is contained in:
parent
142adf057c
commit
db848c6490
6 changed files with 412 additions and 38 deletions
30
README.md
30
README.md
|
|
@ -282,7 +282,7 @@ PCSCF=2001:db8::105
|
||||||
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
||||||
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
|
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
|
||||||
| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |
|
| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |
|
||||||
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER, and a refused SUBSCRIBE final the run loop reads, are logged with the status line and any Retry-After / Warning / Reason / Server headers |
|
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers |
|
||||||
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
|
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
|
||||||
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
|
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
|
||||||
|
|
||||||
|
|
@ -301,7 +301,8 @@ schedule (21 min, growing to 60), a silent one 30 s → 10 min, and a
|
||||||
network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer
|
network `Retry-After` is honoured, up to an hour (the schedule's longest wait): a longer
|
||||||
one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is
|
one is cut to it and the refusal heard again then. While a fresh registration is deferred, the existing SA is
|
||||||
still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a
|
still retried on the reconnect backoff, unless the old registration is known gone: a `403` once a
|
||||||
fresh registration is due, or an attempt that got past the SA setup. A `Dial`
|
fresh registration is due, an attempt that got past the SA setup, or a binding
|
||||||
|
the network terminated (reg-event). A `Dial`
|
||||||
that reaches imsd while it is unregistered brings the next recovery step
|
that reaches imsd while it is unregistered brings the next recovery step
|
||||||
forward (the reconnect, or the fresh attempt once it is due), never a wait
|
forward (the reconnect, or the fresh attempt once it is due), never a wait
|
||||||
the network imposed (a `Retry-After`, or the schedule after a
|
the network imposed (a `Retry-After`, or the schedule after a
|
||||||
|
|
@ -313,11 +314,30 @@ restart of imsd: they are kept in
|
||||||
`/run/imsd.holds` for the address they were set on, with the schedule's place
|
`/run/imsd.holds` for the address they were set on, with the schedule's place
|
||||||
(a restart does not start it over at 21 min), and waited out at the next
|
(a restart does not start it over at 21 min), and waited out at the next
|
||||||
start, before the resume (a refresh's `Retry-After`) or before the fresh
|
start, before the resume (a refresh's `Retry-After`) or before the fresh
|
||||||
registration (a fresh attempt's schedule or `Retry-After`); a reboot or
|
registration (a fresh attempt's schedule or `Retry-After`, a terminated
|
||||||
another address starts clean.
|
binding's wait); a reboot or another address starts clean.
|
||||||
The journal says which (`…; next: fresh registration in N s`, `registering
|
The journal says which (`…; next: fresh registration in N s`, `registering
|
||||||
fresh (recovery attempt N)`, `fresh registration deferred/refused/failed:
|
fresh (recovery attempt N)`, `fresh registration deferred/refused/failed:
|
||||||
…`, `bring-up deferred: …`).
|
…`, `bring-up deferred: …`). The reg-event subscription — the network's own
|
||||||
|
view of the binding, one live dialog per registration, subscribed anew ahead of
|
||||||
|
the lifetime the S-CSCF granted — is retried when refused or unanswered
|
||||||
|
(60 s doubling to 10 min, a `Retry-After` honoured up to an hour: a registration refresh
|
||||||
|
neither brings a scheduled retry forward nor starts its back-off over) and renewed when the S-CSCF ends
|
||||||
|
it for a reason that allows renewal (RFC 6665; after `noresource`,
|
||||||
|
`rejected` or any other reason the renewal does not cover the next
|
||||||
|
registration refresh subscribes anew); an earlier
|
||||||
|
subscription's NOTIFY is still read for the binding's state, but its end is
|
||||||
|
not ours to renew (KPN closes a registration's previous subscription with
|
||||||
|
`noresource` when a new one replaces it); a NOTIFY that terminates the binding triggers a fresh
|
||||||
|
registration (at once, or after the network's `retry-after`, up to an hour; after the
|
||||||
|
call, when one is up; one exception: a REGISTER wait that still gets its 200
|
||||||
|
after such a NOTIFY, a call having kept the flow, leaves that registration
|
||||||
|
standing); after
|
||||||
|
`rejected` and `unregistered` it waits on the refusal schedule (21 min,
|
||||||
|
growing to 60), a wait no `Dial` cuts short — the binding is gone, so
|
||||||
|
nothing is retried meanwhile. One termination counts once, however many
|
||||||
|
NOTIFYs report it (a repeated `probation` report may extend its hold from
|
||||||
|
its own `retry-after`).
|
||||||
|
|
||||||
### imsd-media (per-call, set through imsd's environment)
|
### imsd-media (per-call, set through imsd's environment)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -635,7 +635,7 @@ std::int64_t NowEpoch() {
|
||||||
|
|
||||||
// The recovery policy's holds, kept for the next process: the network's
|
// The recovery policy's holds, kept for the next process: the network's
|
||||||
// word (a Retry-After, the schedule after a refused or throttled fresh
|
// word (a Retry-After, the schedule after a refused or throttled fresh
|
||||||
// attempt) outlives a restart of imsd. They
|
// attempt, a terminated binding's wait) outlives a restart of imsd. They
|
||||||
// are filed under the daemon's address — another one, or a reboot, starts
|
// are filed under the daemon's address — another one, or a reboot, starts
|
||||||
// clean — so the file names that address and the boot, and
|
// clean — so the file names that address and the boot, and
|
||||||
// lives in /run. Seconds left at `written_at`, which is on the boot
|
// lives in /run. Seconds left at `written_at`, which is on the boot
|
||||||
|
|
@ -903,6 +903,7 @@ public:
|
||||||
}
|
}
|
||||||
MaybeRefresh();
|
MaybeRefresh();
|
||||||
MaybeReconnect();
|
MaybeReconnect();
|
||||||
|
MaybeResubscribe();
|
||||||
if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); }
|
if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); }
|
||||||
ReapMedia();
|
ReapMedia();
|
||||||
if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) {
|
if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) {
|
||||||
|
|
@ -918,14 +919,8 @@ public:
|
||||||
if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) {
|
if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) {
|
||||||
Execute(call_->OnInviteResponse(*msg));
|
Execute(call_->OnInviteResponse(*msg));
|
||||||
MaybeDropCall();
|
MaybeDropCall();
|
||||||
} else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) {
|
|
||||||
if (*st >= 300) {
|
|
||||||
Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(*msg)));
|
|
||||||
DumpRaw("imsd-subscribe-fail.raw", *msg);
|
|
||||||
} else {
|
} else {
|
||||||
Log(std::format("reg-event SUBSCRIBE -> {}", *st));
|
OnSubscribeResponse(*msg, *st);
|
||||||
if (*st >= 200) DumpRaw("imsd-subscribe-200.raw", *msg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// shutdown: release an active call, keep the SA/registration for resume
|
// shutdown: release an active call, keep the SA/registration for resume
|
||||||
|
|
@ -1000,6 +995,11 @@ private:
|
||||||
SipFlow sip_;
|
SipFlow sip_;
|
||||||
ServerPorts server_;
|
ServerPorts server_;
|
||||||
std::string subCallid_; // live reg-event subscription dialog (empty: none)
|
std::string subCallid_; // live reg-event subscription dialog (empty: none)
|
||||||
|
bool subPending_ = false; // SUBSCRIBE sent, no final yet
|
||||||
|
double subSentAt_ = 0;
|
||||||
|
double subRetryAt_ = 0; // a failed/ended subscription is renewed at this time (0: none due)
|
||||||
|
double subRenewAt_ = 0; // the live subscription is subscribed anew at this time, ahead of its granted expiry (0: none)
|
||||||
|
double subRetryDelay_ = 60; // 60 s doubling to 10 min between failed SUBSCRIBEs
|
||||||
int portPs_ = 0; // P-CSCF protected server port (reconnect target)
|
int portPs_ = 0; // P-CSCF protected server port (reconnect target)
|
||||||
imsd::engine::RegRecovery recovery_; // what to do when the registration stops working
|
imsd::engine::RegRecovery recovery_; // what to do when the registration stops working
|
||||||
bool saReplaced_ = false; // the running fresh attempt got past the SA setup (the old registration's SAs are gone)
|
bool saReplaced_ = false; // the running fresh attempt got past the SA setup (the old registration's SAs are gone)
|
||||||
|
|
@ -1008,6 +1008,7 @@ private:
|
||||||
imsd::util::Rng rng_;
|
imsd::util::Rng rng_;
|
||||||
std::optional<imsd::engine::CallMachine> call_;
|
std::optional<imsd::engine::CallMachine> call_;
|
||||||
bool dropCall_ = false;
|
bool dropCall_ = false;
|
||||||
|
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
|
||||||
pid_t mediaPid_ = -1;
|
pid_t mediaPid_ = -1;
|
||||||
double deadline_ = 0;
|
double deadline_ = 0;
|
||||||
// reply_ answers the inbound request being dispatched right now;
|
// reply_ answers the inbound request being dispatched right now;
|
||||||
|
|
@ -1094,8 +1095,9 @@ private:
|
||||||
// The network's word holds at bring-up as it does in recovery: a
|
// The network's word holds at bring-up as it does in recovery: a
|
||||||
// hold from before this start is waited out before the resume (a
|
// hold from before this start is waited out before the resume (a
|
||||||
// refresh's Retry-After) or the fresh registration (a fresh attempt's
|
// refresh's Retry-After) or the fresh registration (a fresh attempt's
|
||||||
// schedule or Retry-After), and so is
|
// schedule or Retry-After, a terminated binding's wait), and so is
|
||||||
// one the resume itself earns: a refusal's Retry-After.
|
// one the resume itself earns — a refusal's Retry-After, a binding
|
||||||
|
// a NOTIFY terminated under it.
|
||||||
if (resume != "0") {
|
if (resume != "0") {
|
||||||
if (!WaitForPolicy()) throw Stopping{};
|
if (!WaitForPolicy()) throw Stopping{};
|
||||||
// a registration the network wrote off before this start (the
|
// a registration the network wrote off before this start (the
|
||||||
|
|
@ -1357,7 +1359,8 @@ private:
|
||||||
// the send or of its last provisional. A request arriving on the client
|
// the send or of its last provisional. A request arriving on the client
|
||||||
// flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not
|
// flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not
|
||||||
// read as the reply; a provisional of our own transaction keeps the
|
// read as the reply; a provisional of our own transaction keeps the
|
||||||
// wait going; a response to another transaction is ignored. Throws
|
// wait going; a response to our reg-event SUBSCRIBE is handled as the
|
||||||
|
// run loop handles it, any other transaction's is ignored. Throws
|
||||||
// when nothing answers or the flow dies under a dispatched request.
|
// when nothing answers or the flow dies under a dispatched request.
|
||||||
std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) {
|
std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) {
|
||||||
std::string want = std::format("{} REGISTER", cseq);
|
std::string want = std::format("{} REGISTER", cseq);
|
||||||
|
|
@ -1374,7 +1377,7 @@ private:
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!imsd::sip::ResponseOf(*m, callid, want)) {
|
if (!imsd::sip::ResponseOf(*m, callid, want)) {
|
||||||
Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what));
|
if (!OnSubscribeResponse(*m, *st)) Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (*st >= 200) return *m;
|
if (*st >= 200) return *m;
|
||||||
|
|
@ -1650,6 +1653,10 @@ private:
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
|
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
|
||||||
CloseFlowAfterWait();
|
CloseFlowAfterWait();
|
||||||
|
// the binding terminated under the wait (the flow closed, or
|
||||||
|
// under a call the wait timed out): that verdict named the next
|
||||||
|
// step, this is no silence
|
||||||
|
if (!recovery_.Reconnectable()) return;
|
||||||
if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; }
|
if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; }
|
||||||
RecoveryFailed(F::Silent, 0, "refresh unanswered");
|
RecoveryFailed(F::Silent, 0, "refresh unanswered");
|
||||||
}
|
}
|
||||||
|
|
@ -1680,10 +1687,16 @@ private:
|
||||||
Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1));
|
Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1));
|
||||||
sip_.Close();
|
sip_.Close();
|
||||||
saReplaced_ = false; // this attempt's, whatever fails first
|
saReplaced_ = false; // this attempt's, whatever fails first
|
||||||
subCallid_.clear();
|
// the subscription dialog is the registration's and goes with the
|
||||||
|
// attempt; a retry the network scheduled (its Retry-After) stands
|
||||||
|
// until a fresh registration replaces the old one; an attempt that
|
||||||
|
// fails before the SA setup leaves the old registration and that
|
||||||
|
// retry standing
|
||||||
|
subCallid_.clear(); subPending_ = false; subRenewAt_ = 0;
|
||||||
try {
|
try {
|
||||||
RefreshPani();
|
RefreshPani();
|
||||||
FreshRegister();
|
FreshRegister();
|
||||||
|
subRetryAt_ = 0; // a new registration: its subscription starts afresh
|
||||||
OnRegistered();
|
OnRegistered();
|
||||||
Log("recovered by a fresh registration");
|
Log("recovered by a fresh registration");
|
||||||
} catch (const ThrottledError& e) {
|
} catch (const ThrottledError& e) {
|
||||||
|
|
@ -1712,23 +1725,156 @@ private:
|
||||||
// subscription.
|
// subscription.
|
||||||
void OnRegistered() {
|
void OnRegistered() {
|
||||||
recovery_.OnSuccess();
|
recovery_.OnSuccess();
|
||||||
|
closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before
|
||||||
KeepHolds();
|
KeepHolds();
|
||||||
bool was = registered_;
|
bool was = registered_;
|
||||||
registered_ = true;
|
registered_ = true;
|
||||||
EmitStatus(!was);
|
EmitStatus(!was);
|
||||||
lastRefresh_ = Mono(); lastKa_ = Mono();
|
lastRefresh_ = Mono(); lastKa_ = Mono();
|
||||||
|
// a refresh or a reconnect opens no subscription dialog while imsd
|
||||||
|
// holds one for the registration or has a retry scheduled; a fresh
|
||||||
|
// registration (or a start) has none yet, and a fresh attempt drops
|
||||||
|
// the one it holds before it sends. A retry already scheduled (the
|
||||||
|
// network's Retry-After, the backoff) is MaybeResubscribe's to send,
|
||||||
|
// not a registration's to bring forward — nor to start its backoff
|
||||||
|
// over: that resets with a subscription that starts afresh here, or
|
||||||
|
// is accepted
|
||||||
|
if (subCallid_.empty() && !subPending_ && subRetryAt_ == 0) { subRetryDelay_ = 60; SubscribeRegEvent(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to the reg event package (TS 24.229 5.1.1.3): one dialog per
|
||||||
|
// registration, sent over the registration flow; the S-CSCF's immediate
|
||||||
|
// NOTIFY (logged in DispatchRequest) is the network's own view of our
|
||||||
|
// bindings. Failure is non-fatal — registration is unaffected.
|
||||||
|
void SubscribeRegEvent() {
|
||||||
|
subRetryAt_ = 0; subRenewAt_ = 0;
|
||||||
|
subCallid_ = std::format("{}@{}", rng_.Token(16), local_);
|
||||||
|
std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16));
|
||||||
|
if (sip_.Send(sub)) { Log("reg-event SUBSCRIBE sent"); subPending_ = true; subSentAt_ = Mono(); }
|
||||||
|
else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); ScheduleResubscribe(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A response on the client flow that belongs to our reg-event
|
||||||
|
// SUBSCRIBE, wherever the flow is read (the run loop, a REGISTER
|
||||||
|
// wait). False when it is not ours.
|
||||||
|
bool OnSubscribeResponse(const std::string& msg, int st) {
|
||||||
|
if (subCallid_.empty() || imsd::sip::Header(msg, "Call-ID") != subCallid_) return false;
|
||||||
|
if (!imsd::sip::Header(msg, "CSeq").value_or("").ends_with("SUBSCRIBE")) return false;
|
||||||
|
if (st >= 300) {
|
||||||
|
Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(msg)));
|
||||||
|
DumpRaw("imsd-subscribe-fail.raw", msg);
|
||||||
|
subPending_ = false; subCallid_.clear();
|
||||||
|
ScheduleResubscribe(imsd::sip::RetryAfter(msg).value_or(0));
|
||||||
|
} else {
|
||||||
|
Log(std::format("reg-event SUBSCRIBE -> {}", st));
|
||||||
|
if (st >= 200) {
|
||||||
|
DumpRaw("imsd-subscribe-200.raw", msg); subPending_ = false; subRetryDelay_ = 60;
|
||||||
|
if (auto e = imsd::sip::GrantedExpires(msg)) ArmSubscriptionRenewal(*e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The subscription is part of being registered (TS 24.229 5.1.1.3): a
|
||||||
|
// refused or unanswered SUBSCRIBE is retried, 60 s doubling to 10 min
|
||||||
|
// (a Retry-After wins), and a subscription the S-CSCF ends is renewed.
|
||||||
|
// Until now one failure left the daemon deaf to the network's view of
|
||||||
|
// its binding — O2 UK 2026-09-24: SUBSCRIBE 500, the registration
|
||||||
|
// killed 8 s later, nothing heard.
|
||||||
|
void ScheduleResubscribe(long retryAfter) {
|
||||||
|
double wait = std::max(subRetryDelay_, static_cast<double>(std::min(retryAfter, imsd::engine::RegRecovery::RetryAfterMax)));
|
||||||
|
subRetryDelay_ = std::min(subRetryDelay_ * 2, 600.0);
|
||||||
|
subRetryAt_ = Mono() + wait;
|
||||||
|
Log(std::format("reg-event: next SUBSCRIBE in {:.0f} s", wait));
|
||||||
|
}
|
||||||
|
void MaybeResubscribe() {
|
||||||
|
if (subPending_ && Mono() - subSentAt_ > 32) { // Timer F: no final at all
|
||||||
|
Log("reg-event SUBSCRIBE unanswered");
|
||||||
|
subPending_ = false; subCallid_.clear();
|
||||||
|
ScheduleResubscribe(0);
|
||||||
|
}
|
||||||
|
if (call_ || !registered_ || !sip_.Alive()) return;
|
||||||
|
if (!subCallid_.empty() && !subPending_ && subRenewAt_ > 0 && Mono() >= subRenewAt_) {
|
||||||
|
// the granted lifetime is running out: a new dialog (the S-CSCF
|
||||||
|
// closes the previous one — KPN with noresource), on the same
|
||||||
|
// registration
|
||||||
|
Log("reg-event: the subscription nears its expiry; subscribing anew");
|
||||||
|
subCallid_.clear(); subRenewAt_ = 0;
|
||||||
|
SubscribeRegEvent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subRetryAt_ == 0 || Mono() < subRetryAt_) return;
|
||||||
SubscribeRegEvent();
|
SubscribeRegEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh
|
// The network granted the subscription `expires` seconds (a SUBSCRIBE
|
||||||
// dialog each time, sent over the registration flow; the S-CSCF's
|
// 2xx, a NOTIFY's Subscription-State): subscribe anew 10 min before the
|
||||||
// immediate NOTIFY (logged in DispatchRequest) is the network's own view
|
// end, or halfway through a shorter grant.
|
||||||
// of our bindings. Failure is non-fatal — registration is unaffected.
|
void ArmSubscriptionRenewal(long expires) {
|
||||||
void SubscribeRegEvent() {
|
double ahead = expires > 1200 ? expires - 600.0 : expires / 2.0;
|
||||||
subCallid_ = std::format("{}@{}", rng_.Token(16), local_);
|
subRenewAt_ = Mono() + std::max(ahead, 30.0);
|
||||||
std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16));
|
Log(std::format("reg-event: subscription granted {} s; renewed in {:.0f} s", expires, subRenewAt_ - Mono()));
|
||||||
if (sip_.Send(sub)) Log("reg-event SUBSCRIBE sent");
|
}
|
||||||
else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); }
|
|
||||||
|
// The network's view of our binding (RFC 3680 reginfo), read from any
|
||||||
|
// reg-event NOTIFY — a subscription an earlier process left behind
|
||||||
|
// carries the same account (O2, 2026-09-24: the previous dialog's NOTIFY
|
||||||
|
// reached a restarted daemon whose own SUBSCRIBE had just got 500). A
|
||||||
|
// binding the registrar terminated needs a new registration: the
|
||||||
|
// policy decides when (TS 24.229 5.4.1.5, 5.1.1.7 — now for deactivated
|
||||||
|
// and expired, after retry-after for probation, on the refusal schedule
|
||||||
|
// for rejected and unregistered). Active or not listed: nothing to do.
|
||||||
|
// Subscription state is per dialog: only our live dialog's end is ours
|
||||||
|
// to renew, unless the binding went with it.
|
||||||
|
void OnRegEventNotify(const std::string& msg, std::string_view body) {
|
||||||
|
using S = imsd::sip::RegInfoContact::State;
|
||||||
|
auto c = imsd::sip::RegInfoContactState(body, imsd::msg::ContactUser(ctx_));
|
||||||
|
bool bindingGone = false;
|
||||||
|
if (c.state == S::Terminated) {
|
||||||
|
bindingGone = true;
|
||||||
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
||||||
|
// a call lives on the client flow: the flow closes when the
|
||||||
|
// call is dropped, not under it — the call goes on, or the
|
||||||
|
// network releases it, and the fresh step follows the call as
|
||||||
|
// every other recovery step does
|
||||||
|
if (call_) closeFlowWhenIdle_ = true;
|
||||||
|
else
|
||||||
|
sip_.Close();
|
||||||
|
subCallid_.clear(); subPending_ = false; subRetryAt_ = 0; subRenewAt_ = 0;
|
||||||
|
recovery_.OnBindingTerminated(Boot(), c.event, c.retryAfter);
|
||||||
|
KeepHolds();
|
||||||
|
Log(std::format("reg-event: our binding is terminated ({}); {}", c.event, NextStep()));
|
||||||
|
} else if (c.state == S::Active) {
|
||||||
|
Log("reg-event: our binding is active");
|
||||||
|
}
|
||||||
|
auto ss = imsd::sip::Header(msg, "Subscription-State");
|
||||||
|
std::string_view callid = imsd::sip::Header(msg, "Call-ID").value_or("");
|
||||||
|
bool ours = !subCallid_.empty() && callid == subCallid_;
|
||||||
|
if (ss && ss->starts_with("active") && ours)
|
||||||
|
if (auto e = IntAfter(*ss, "expires=")) ArmSubscriptionRenewal(*e);
|
||||||
|
if (ss && ss->starts_with("terminated") && !ours && !bindingGone) {
|
||||||
|
Log(std::format("reg-event: an earlier subscription ({}) ended ({}); ours ({}) stands", callid, *ss, subCallid_.empty() ? "none" : subCallid_));
|
||||||
|
} else if (ss && ss->starts_with("terminated") && !bindingGone) {
|
||||||
|
// RFC 6665 §4.1.3: renew after deactivated, timeout, giveup and
|
||||||
|
// probation (after its retry-after, on the 60 s retry floor); not after rejected
|
||||||
|
// or noresource. KPN's S-CSCF ends a registration's previous
|
||||||
|
// subscription with noresource when a new one replaces it (dev
|
||||||
|
// phone 2026-09-25, Call-IDs compared); the 2026-09-24 reading
|
||||||
|
// that it ends every subscription came from opening a new one
|
||||||
|
// at every refresh and taking that closure for our own
|
||||||
|
std::string reason;
|
||||||
|
if (auto r = ss->find("reason="); r != std::string_view::npos) {
|
||||||
|
std::string_view v = ss->substr(r + 7);
|
||||||
|
reason = std::string(v.substr(0, v.find_first_of(";, \t\r")));
|
||||||
|
}
|
||||||
|
subCallid_.clear(); subPending_ = false; subRenewAt_ = 0;
|
||||||
|
if (reason == "deactivated" || reason == "timeout" || reason == "giveup" || reason == "probation") {
|
||||||
|
Log(std::format("reg-event: subscription ended ({}); renewing", *ss));
|
||||||
|
ScheduleResubscribe(IntAfter(*ss, "retry-after=").value_or(0));
|
||||||
|
} else {
|
||||||
|
Log(std::format("reg-event: subscription ended ({}); the next refresh subscribes anew", *ss));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- command handling -------------------------------------------------
|
// ---- command handling -------------------------------------------------
|
||||||
|
|
@ -1762,7 +1908,9 @@ private:
|
||||||
// recovery path one immediate shot first: user action cuts the
|
// recovery path one immediate shot first: user action cuts the
|
||||||
// backoff short, never a wait the network imposed (a
|
// backoff short, never a wait the network imposed (a
|
||||||
// Retry-After, the schedule after a refused or throttled fresh
|
// Retry-After, the schedule after a refused or throttled fresh
|
||||||
// attempt — the policy keeps those). The shot's REGISTER wait dispatches
|
// attempt, the one after a binding the network rejected or
|
||||||
|
// unregistered, probation's retry-after — the policy keeps those).
|
||||||
|
// The shot's REGISTER wait dispatches
|
||||||
// requests, so an incoming call may have arrived under it: the
|
// requests, so an incoming call may have arrived under it: the
|
||||||
// same rule again.
|
// same rule again.
|
||||||
if (!sip_.Alive()) {
|
if (!sip_.Alive()) {
|
||||||
|
|
@ -1813,7 +1961,12 @@ private:
|
||||||
? std::string_view{}
|
? std::string_view{}
|
||||||
: std::string_view(msg).substr(at + 4);
|
: std::string_view(msg).substr(at + 4);
|
||||||
if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body));
|
if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body));
|
||||||
else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body));
|
else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) {
|
||||||
|
Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body));
|
||||||
|
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
|
||||||
|
OnRegEventNotify(msg, body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
|
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1894,7 +2047,13 @@ private:
|
||||||
}
|
}
|
||||||
|
|
||||||
void MaybeDropCall() {
|
void MaybeDropCall() {
|
||||||
if (dropCall_) { call_.reset(); callReply_ = nullptr; dropCall_ = false; }
|
if (!dropCall_) return;
|
||||||
|
call_.reset(); callReply_ = nullptr; dropCall_ = false;
|
||||||
|
if (closeFlowWhenIdle_) {
|
||||||
|
closeFlowWhenIdle_ = false;
|
||||||
|
Log("the call is over: closing the flow of the binding the network terminated under it");
|
||||||
|
sip_.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void StartMedia(const imsd::engine::MediaLeg& leg) {
|
void StartMedia(const imsd::engine::MediaLeg& leg) {
|
||||||
|
|
|
||||||
|
|
@ -93,11 +93,13 @@ export namespace imsd::engine {
|
||||||
// unbarred registers within the hour); once a fresh registration is
|
// unbarred registers within the hour); once a fresh registration is
|
||||||
// due it also says the old registration is gone (KPN drops it the
|
// due it also says the old registration is gone (KPN drops it the
|
||||||
// moment an initial REGISTER arrives, throttled or not), as does a
|
// moment an initial REGISTER arrives, throttled or not), as does a
|
||||||
// fresh attempt that got past the SA setup, and a resume that failed at
|
// fresh attempt that got past the SA setup, a binding the network
|
||||||
// bring-up (OldGone) — no reconnect over the old SA from then on. Every wait
|
// terminated, and a resume that failed at bring-up (OldGone) — no
|
||||||
|
// reconnect over the old SA from then on. Every wait
|
||||||
// is either the self-imposed backoff, which a user's Dial cuts short,
|
// is either the self-imposed backoff, which a user's Dial cuts short,
|
||||||
// or the network's own word (a Retry-After, the schedule after a
|
// or the network's own word (a Retry-After, the schedule after a
|
||||||
// refused or throttled fresh attempt), which nothing cuts short — a
|
// refused or throttled fresh attempt, a terminated binding's probation
|
||||||
|
// or its refusal schedule), which nothing cuts short — a
|
||||||
// restart of the process neither: the shell keeps the holds (Holds)
|
// restart of the process neither: the shell keeps the holds (Holds)
|
||||||
// and hands them to the next one (Restore). The shell supplies the
|
// and hands them to the next one (Restore). The shell supplies the
|
||||||
// clock and the network; this holds the deadlines and names the next
|
// clock and the network; this holds the deadlines and names the next
|
||||||
|
|
@ -137,10 +139,11 @@ export namespace imsd::engine {
|
||||||
int throttleMinutes = ThrottleMinutesMin;
|
int throttleMinutes = ThrottleMinutesMin;
|
||||||
bool fresh = false; // the next registration attempt is a fresh one
|
bool fresh = false; // the next registration attempt is a fresh one
|
||||||
bool oldGone = false; // the old registration is gone: nothing to reconnect over
|
bool oldGone = false; // the old registration is gone: nothing to reconnect over
|
||||||
|
bool terminated = false; // the network terminated our binding, and the policy has counted it
|
||||||
// the deadlines, on the shell's clock
|
// the deadlines, on the shell's clock
|
||||||
double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it
|
double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it
|
||||||
double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it
|
double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it
|
||||||
double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After)
|
double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After, probation, rejected/unregistered)
|
||||||
|
|
||||||
bool Fresh() const { return fresh; }
|
bool Fresh() const { return fresh; }
|
||||||
bool Reconnectable() const { return !oldGone; }
|
bool Reconnectable() const { return !oldGone; }
|
||||||
|
|
@ -187,13 +190,18 @@ export namespace imsd::engine {
|
||||||
// registration is due says the old registration is gone (KPN drops
|
// registration is due says the old registration is gone (KPN drops
|
||||||
// it the moment an initial REGISTER arrives, throttled or not).
|
// it the moment an initial REGISTER arrives, throttled or not).
|
||||||
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
|
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
|
||||||
|
if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast<double>(std::min(retryAfter, RetryAfterMax)));
|
||||||
|
// A cycle over a registration already known gone — the network
|
||||||
|
// terminated the binding while the cycle waited for its final —
|
||||||
|
// fails by definition: not a silent cycle, not a refusal, and no
|
||||||
|
// backoff before the fresh registration that verdict scheduled.
|
||||||
|
if (oldGone) return;
|
||||||
if (f == Failure::Refused) refusals++;
|
if (f == Failure::Refused) refusals++;
|
||||||
else silentCycles++;
|
else silentCycles++;
|
||||||
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
|
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
|
||||||
if (status == 403 && fresh) oldGone = true;
|
if (status == 403 && fresh) oldGone = true;
|
||||||
backoffUntil = now + reconnectDelay;
|
backoffUntil = now + reconnectDelay;
|
||||||
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);
|
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);
|
||||||
if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast<double>(std::min(retryAfter, RetryAfterMax)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failed fresh registration; `saReplaced`: it got past the SA
|
// A failed fresh registration; `saReplaced`: it got past the SA
|
||||||
|
|
@ -219,6 +227,32 @@ export namespace imsd::engine {
|
||||||
freshHoldUntil = now + wait;
|
freshHoldUntil = now + wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The network terminated our binding (a reg-event NOTIFY, RFC
|
||||||
|
// 3680): the old registration is gone. deactivated and expired
|
||||||
|
// mean a fresh registration now; probation after the network's
|
||||||
|
// retry-after (up to RetryAfterMax); rejected and unregistered on the
|
||||||
|
// refusal schedule —
|
||||||
|
// the network's word is neither hammered nor taken as final. One
|
||||||
|
// termination counts once, however often it is reported (every
|
||||||
|
// reg-event dialog of the account carries it, a NOTIFY may be
|
||||||
|
// retransmitted), and never shortens a hold already running.
|
||||||
|
void OnBindingTerminated(double now, std::string_view event, long retryAfter = 0) {
|
||||||
|
bool reported = terminated;
|
||||||
|
terminated = true;
|
||||||
|
oldGone = true;
|
||||||
|
fresh = true;
|
||||||
|
if (event == "probation") {
|
||||||
|
freshHoldUntil = std::max(freshHoldUntil, now + static_cast<double>(std::clamp(retryAfter, 1L, RetryAfterMax)));
|
||||||
|
} else if (reported) {
|
||||||
|
return;
|
||||||
|
} else if (event == "rejected" || event == "unregistered") {
|
||||||
|
freshHoldUntil = std::max(freshHoldUntil, now + throttleMinutes * 60.0);
|
||||||
|
throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax);
|
||||||
|
} else {
|
||||||
|
backoffUntil = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The stored registration is not there to reconnect over (a resume
|
// The stored registration is not there to reconnect over (a resume
|
||||||
// that failed at bring-up found its SAs unusable, or there are none).
|
// that failed at bring-up found its SAs unusable, or there are none).
|
||||||
void OldGone() { oldGone = true; fresh = true; }
|
void OldGone() { oldGone = true; fresh = true; }
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,60 @@ export namespace imsd::sip {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Our binding in a reg-event NOTIFY body (RFC 3680 reginfo): the
|
||||||
|
// <contact> whose <uri> carries `user` as its SIP user part, across every
|
||||||
|
// <registration> of the implicit set (the sip: and the tel: identity
|
||||||
|
// list the same contact). Active anywhere = Active; otherwise the first
|
||||||
|
// terminated occurrence's event and retry-after attribute; NotListed
|
||||||
|
// when no contact of ours is in the document — a partial update about
|
||||||
|
// other bindings, or a previous registration's binding (the contact our
|
||||||
|
// own fresh registration deactivated, O2 UK 2026-09-24).
|
||||||
|
struct RegInfoContact {
|
||||||
|
enum class State { NotListed, Active, Terminated };
|
||||||
|
State state = State::NotListed;
|
||||||
|
std::string event; // terminated: deactivated | probation | rejected | unregistered | expired | …
|
||||||
|
long retryAfter = 0; // terminated: the retry-after attribute (probation), seconds
|
||||||
|
};
|
||||||
|
RegInfoContact RegInfoContactState(std::string_view reginfo, std::string_view user) {
|
||||||
|
constexpr std::size_t npos = std::string_view::npos;
|
||||||
|
RegInfoContact out;
|
||||||
|
std::string needle = std::format(":{}@", user); // sip:<user>@… / sips:<user>@…
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while ((pos = reginfo.find("<contact", pos)) != npos) {
|
||||||
|
std::size_t tagEnd = reginfo.find('>', pos);
|
||||||
|
if (tagEnd == npos) break;
|
||||||
|
std::string_view tag = reginfo.substr(pos, tagEnd - pos);
|
||||||
|
std::size_t end = reginfo.find("</contact>", tagEnd);
|
||||||
|
std::string_view body = reginfo.substr(tagEnd + 1, end == npos ? npos : end - tagEnd - 1);
|
||||||
|
pos = end == npos ? reginfo.size() : end + 10;
|
||||||
|
std::size_t u = body.find("<uri>");
|
||||||
|
if (u == npos) continue;
|
||||||
|
std::size_t ue = body.find("</uri>", u);
|
||||||
|
std::string_view uri = Trim(body.substr(u + 5, ue == npos ? npos : ue - u - 5));
|
||||||
|
if (!uri.contains(needle)) continue;
|
||||||
|
auto attr = [&](std::string_view name) -> std::string_view {
|
||||||
|
std::string key = std::format(" {}=\"", name);
|
||||||
|
std::size_t a = tag.find(key);
|
||||||
|
if (a == npos) return {};
|
||||||
|
std::size_t b = tag.find('"', a + key.size());
|
||||||
|
return tag.substr(a + key.size(), b == npos ? npos : b - a - key.size());
|
||||||
|
};
|
||||||
|
std::string_view state = attr("state");
|
||||||
|
if (state == "active") {
|
||||||
|
out.state = RegInfoContact::State::Active;
|
||||||
|
out.event.clear();
|
||||||
|
out.retryAfter = 0;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (state == "terminated" && out.state == RegInfoContact::State::NotListed) {
|
||||||
|
out.state = RegInfoContact::State::Terminated;
|
||||||
|
out.event = std::string(attr("event"));
|
||||||
|
out.retryAfter = LeadingDigits(attr("retry-after")).value_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Where a UDP response to this request must go (RFC 3261 18.2.2): the
|
// Where a UDP response to this request must go (RFC 3261 18.2.2): the
|
||||||
// top Via's sent-by port — unless the Via carries rport, in which case
|
// top Via's sent-by port — unless the Via carries rport, in which case
|
||||||
// the caller should reply to the request's source port instead
|
// the caller should reply to the request's source port instead
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
|
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
|
||||||
// the refusal and silence thresholds, local refusals, the reconnect and
|
// the refusal and silence thresholds, local refusals, the reconnect and
|
||||||
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
|
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
|
||||||
// may cut short, when the old registration counts as gone, and the holds
|
// may cut short, when the old registration counts as gone, the holds a
|
||||||
// a restart takes over (Holds/Restore). Pure reducers, no I/O.
|
// restart takes over (Holds/Restore), and a binding the network terminated
|
||||||
|
// (OnBindingTerminated). Pure reducers, no I/O.
|
||||||
import std;
|
import std;
|
||||||
import Imsd;
|
import Imsd;
|
||||||
|
|
||||||
|
|
@ -977,6 +978,65 @@ int main() {
|
||||||
t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true);
|
t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true);
|
||||||
Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule");
|
Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule");
|
||||||
}
|
}
|
||||||
|
// the network terminated our binding
|
||||||
|
{
|
||||||
|
RegRecovery n;
|
||||||
|
n.OnBindingTerminated(1000, "expired");
|
||||||
|
Check(n.Fresh() && !n.Reconnectable() && freshAt(n, 1000, 0), "expired: fresh now, nothing to reconnect to");
|
||||||
|
RegRecovery o;
|
||||||
|
o.OnBindingTerminated(1000, "probation", 300);
|
||||||
|
Check(freshAt(o, 1000, 1300), "probation: after the network's retry-after");
|
||||||
|
o.OnDial();
|
||||||
|
Check(freshAt(o, 1000, 1300), "…which a Dial does not cut");
|
||||||
|
RegRecovery p;
|
||||||
|
p.OnBindingTerminated(1000, "probation");
|
||||||
|
Check(freshAt(p, 1000, 1001), "probation without a retry-after: at least 1 s");
|
||||||
|
RegRecovery q;
|
||||||
|
q.OnBindingTerminated(1000, "rejected");
|
||||||
|
Check(freshAt(q, 1000, 1000 + 21 * 60) && !q.Reconnectable(), "rejected: on the refusal schedule, a hold, the old SA not retried");
|
||||||
|
q.OnBindingTerminated(1001, "rejected");
|
||||||
|
q.OnBindingTerminated(2000, "unregistered");
|
||||||
|
Check(q.freshHoldUntil == 1000 + 21 * 60 && q.throttleMinutes == 31, "the same termination reported again (another dialog, a retransmission): counted once");
|
||||||
|
RegRecovery u;
|
||||||
|
u.OnBindingTerminated(1000, "deactivated");
|
||||||
|
u.OnFreshFailure(1000, F::Silent);
|
||||||
|
u.OnBindingTerminated(1005, "deactivated");
|
||||||
|
Check(freshAt(u, 1005, 1030), "…nor does a repeat cut the backoff of the attempt made since");
|
||||||
|
RegRecovery v;
|
||||||
|
v.OnFreshFailure(1000, F::Refused, 3000);
|
||||||
|
v.OnBindingTerminated(1010, "rejected");
|
||||||
|
Check(!v.Reconnectable() && freshAt(v, 1010, 4000) && v.throttleMinutes == 46, "a rejected binding never shortens the network's Retry-After");
|
||||||
|
RegRecovery w;
|
||||||
|
w.OnBindingTerminated(1000, "probation", 3000);
|
||||||
|
w.OnBindingTerminated(1010, "probation", 60);
|
||||||
|
Check(freshAt(w, 1010, 4000), "…nor a second probation report the first one's retry-after");
|
||||||
|
RegRecovery x;
|
||||||
|
x.OnBindingTerminated(1000, "probation", 86400);
|
||||||
|
Check(freshAt(x, 1000, 1000 + 3600), "probation's retry-after is cut to the hour like a Retry-After");
|
||||||
|
// terminated while a reconnect cycle waited for its re-REGISTER final: the cycle's failure is no news
|
||||||
|
RegRecovery y;
|
||||||
|
y.OnReconnectFailure(1000, F::Silent);
|
||||||
|
y.OnBindingTerminated(1010, "expired");
|
||||||
|
y.OnReconnectFailure(1011, F::Silent);
|
||||||
|
Check(y.silentCycles == 1 && freshAt(y, 1011, 0), "the flow closed under the cycle: not a silent cycle, the fresh registration now");
|
||||||
|
y.OnReconnectFailure(1012, F::Refused, 90, 503);
|
||||||
|
Check(y.refusals == 0 && freshAt(y, 1012, 1102), "…a refusal with a Retry-After on it: not counted, the network's hold kept");
|
||||||
|
}
|
||||||
|
// a terminated binding's verdict outlives the process with its hold
|
||||||
|
{
|
||||||
|
RegRecovery tg;
|
||||||
|
tg.OnBindingTerminated(1000, "rejected");
|
||||||
|
auto htg = tg.Holds(1100);
|
||||||
|
Check(htg.Any() && htg.oldGone && htg.freshHold == 1000 + 21 * 60 - 1100, "a terminated binding's holds carry that the old registration is gone");
|
||||||
|
RegRecovery th;
|
||||||
|
th.Restore(50, htg);
|
||||||
|
Check(!th.Reconnectable() && freshAt(th, 50, 50 + htg.freshHold), "…and the restart does not reconnect over its SAs: the fresh attempt waits what was left");
|
||||||
|
RegRecovery ti;
|
||||||
|
ti.OnBindingTerminated(1000, "probation", 1800);
|
||||||
|
RegRecovery tj;
|
||||||
|
tj.Restore(0, ti.Holds(1300));
|
||||||
|
Check(!tj.Reconnectable() && freshAt(tj, 0, 1500), "probation's retry-after survives a restart the same way");
|
||||||
|
}
|
||||||
// the network's holds outlive the process
|
// the network's holds outlive the process
|
||||||
{
|
{
|
||||||
RegRecovery a;
|
RegRecovery a;
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,53 @@ int main() {
|
||||||
Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it");
|
Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RegInfoContactState: our binding in a reg-event NOTIFY (RFC 3680)
|
||||||
|
{
|
||||||
|
using S = RegInfoContact::State;
|
||||||
|
auto contact = [](std::string_view id, std::string_view state, std::string_view extra, std::string_view user) {
|
||||||
|
return std::format(
|
||||||
|
"\t\t<contact id=\"{}\" state=\"{}\"{}>\n"
|
||||||
|
"\t\t\t<uri>sip:{}@[2a03:db8::1]:45062</uri>\n"
|
||||||
|
"\t\t\t<unknown-param name=\"+sip.instance\">"<urn:gsma:imei:35901961-000000-0>" </unknown-param>\n"
|
||||||
|
"\t\t</contact>\n", id, state, extra, user);
|
||||||
|
};
|
||||||
|
auto reginfo = [](std::string_view contacts) {
|
||||||
|
return std::format(
|
||||||
|
"<?xml version=\"1.0\"?>\n"
|
||||||
|
"<reginfo xmlns=\"urn:ietf:params:xml:ns:reginfo\" version=\"1\" state=\"full\">\n"
|
||||||
|
"\t<registration aor=\"sip:+447700900000@example.net\" id=\"2\" state=\"active\">\n{0}\t</registration>\n"
|
||||||
|
"\t<registration aor=\"tel:+447700900000\" id=\"3\" state=\"active\">\n{0}\t</registration>\n"
|
||||||
|
"</reginfo>\n", contacts);
|
||||||
|
};
|
||||||
|
const std::string me = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||||
|
const std::string old = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
// the first NOTIFY of a registration: our contact active in both registrations
|
||||||
|
auto r0 = RegInfoContactState(reginfo(contact("3", "active", " event=\"created\" expires=\"10800\"", me)), me);
|
||||||
|
Check(r0.state == S::Active, "our contact active -> Active");
|
||||||
|
// O2 2026-09-24 10:43: the previous registration's contact deactivated by ours, ours created
|
||||||
|
std::string both = contact("3", "terminated", " event=\"deactivated\"", old) + contact("4", "active", " event=\"created\" expires=\"10800\"", me);
|
||||||
|
auto r1 = RegInfoContactState(reginfo(both), me);
|
||||||
|
Check(r1.state == S::Active, "old contact terminated + ours active -> Active (the old one is not ours)");
|
||||||
|
auto r1o = RegInfoContactState(reginfo(both), old);
|
||||||
|
Check(r1o.state == S::Terminated && r1o.event == "deactivated" && r1o.retryAfter == 0, "seen from the old contact's side: Terminated/deactivated");
|
||||||
|
// a network-initiated deregistration of our own binding
|
||||||
|
auto r2 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"probation\" retry-after=\"120\"", me)), me);
|
||||||
|
Check(r2.state == S::Terminated && r2.event == "probation" && r2.retryAfter == 120, "probation with retry-after");
|
||||||
|
auto r3 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"rejected\"", me)), me);
|
||||||
|
Check(r3.state == S::Terminated && r3.event == "rejected", "rejected");
|
||||||
|
// active in one registration of the set beats terminated in another
|
||||||
|
std::string mixed =
|
||||||
|
"<reginfo version=\"2\" state=\"partial\">\n<registration aor=\"tel:+1\" id=\"3\" state=\"active\">\n" + contact("4", "terminated", " event=\"expired\"", me) +
|
||||||
|
"</registration>\n<registration aor=\"sip:+1@x\" id=\"2\" state=\"active\">\n" + contact("4", "active", "", me) + "</registration>\n</reginfo>";
|
||||||
|
Check(RegInfoContactState(mixed, me).state == S::Active, "active anywhere in the set -> Active");
|
||||||
|
// not ours at all
|
||||||
|
auto r4 = RegInfoContactState(reginfo(contact("9", "terminated", " event=\"unregistered\"", old)), me);
|
||||||
|
Check(r4.state == S::NotListed && r4.event.empty(), "a document without our contact -> NotListed");
|
||||||
|
Check(RegInfoContactState("", me).state == S::NotListed, "empty body -> NotListed");
|
||||||
|
Check(RegInfoContactState("<reginfo><registration><contact id=\"1\" state=\"active\"><uri>sip:aaaaaaaa@x</uri></contact>", me).state == S::NotListed, "a user part that is only a prefix of ours does not match");
|
||||||
|
Check(RegInfoContactState("<contact state=\"terminated\" event=\"expired\"><uri>sip:" + me + "@h</uri>", me).state == S::Terminated, "an unterminated element still parses");
|
||||||
|
}
|
||||||
|
|
||||||
if (Failures == 0) std::println("Sip: all tests passed");
|
if (Failures == 0) std::println("Sip: all tests passed");
|
||||||
return Failures;
|
return Failures;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue