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

@ -93,11 +93,13 @@ export namespace imsd::engine {
// unbarred registers within the hour); once a fresh registration is
// due it also says the old registration is gone (KPN drops it the
// 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
// bring-up (OldGone) — no reconnect over the old SA from then on. Every wait
// fresh attempt that got past the SA setup, a binding the network
// 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,
// 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)
// and hands them to the next one (Restore). The shell supplies the
// clock and the network; this holds the deadlines and names the next
@ -137,10 +139,11 @@ export namespace imsd::engine {
int throttleMinutes = ThrottleMinutesMin;
bool fresh = false; // the next registration attempt is a fresh one
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
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 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 Reconnectable() const { return !oldGone; }
@ -187,13 +190,18 @@ export namespace imsd::engine {
// registration is due says the old registration is gone (KPN drops
// it the moment an initial REGISTER arrives, throttled or not).
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++;
else silentCycles++;
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
if (status == 403 && fresh) oldGone = true;
backoffUntil = now + reconnectDelay;
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
@ -219,6 +227,32 @@ export namespace imsd::engine {
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
// that failed at bring-up found its SAs unusable, or there are none).
void OldGone() { oldGone = true; fresh = true; }