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

@ -14,8 +14,9 @@
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
// the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, and the holds
// a restart takes over (Holds/Restore). Pure reducers, no I/O.
// may cut short, when the old registration counts as gone, the holds a
// restart takes over (Holds/Restore), and a binding the network terminated
// (OnBindingTerminated). Pure reducers, no I/O.
import std;
import Imsd;
@ -977,6 +978,65 @@ int main() {
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");
}
// 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
{
RegRecovery a;