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:
Jorijn van der Graaf 2026-10-03 19:36:23 +02:00
commit db848c6490
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 412 additions and 38 deletions

View file

@ -154,6 +154,60 @@ export namespace imsd::sip {
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
// 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