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

@ -282,7 +282,7 @@ PCSCF=2001:db8::105
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `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 |
| `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 |
| `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
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
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
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
@ -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
(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
registration (a fresh attempt's schedule or `Retry-After`); a reboot or
another address starts clean.
registration (a fresh attempt's schedule or `Retry-After`, a terminated
binding's wait); a reboot or another address starts clean.
The journal says which (`…; next: fresh registration in N s`, `registering
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)

View file

@ -635,7 +635,7 @@ std::int64_t NowEpoch() {
// 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
// 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
// clean — so the file names that address and the boot, and
// lives in /run. Seconds left at `written_at`, which is on the boot
@ -903,6 +903,7 @@ public:
}
MaybeRefresh();
MaybeReconnect();
MaybeResubscribe();
if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); }
ReapMedia();
if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) {
@ -918,14 +919,8 @@ public:
if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) {
Execute(call_->OnInviteResponse(*msg));
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 {
Log(std::format("reg-event SUBSCRIBE -> {}", *st));
if (*st >= 200) DumpRaw("imsd-subscribe-200.raw", *msg);
}
} else {
OnSubscribeResponse(*msg, *st);
}
}
// shutdown: release an active call, keep the SA/registration for resume
@ -1000,6 +995,11 @@ private:
SipFlow sip_;
ServerPorts server_;
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)
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)
@ -1008,6 +1008,7 @@ private:
imsd::util::Rng rng_;
std::optional<imsd::engine::CallMachine> call_;
bool dropCall_ = false;
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
pid_t mediaPid_ = -1;
double deadline_ = 0;
// 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
// hold from before this start is waited out before the resume (a
// refresh's Retry-After) or the fresh registration (a fresh attempt's
// schedule or Retry-After), and so is
// one the resume itself earns: a refusal's Retry-After.
// schedule or Retry-After, a terminated binding's wait), and so is
// one the resume itself earns — a refusal's Retry-After, a binding
// a NOTIFY terminated under it.
if (resume != "0") {
if (!WaitForPolicy()) throw Stopping{};
// 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
// flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not
// 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.
std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) {
std::string want = std::format("{} REGISTER", cseq);
@ -1374,7 +1377,7 @@ private:
continue;
}
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;
}
if (*st >= 200) return *m;
@ -1650,6 +1653,10 @@ private:
} catch (const std::exception& e) {
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
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; }
RecoveryFailed(F::Silent, 0, "refresh unanswered");
}
@ -1680,10 +1687,16 @@ private:
Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1));
sip_.Close();
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 {
RefreshPani();
FreshRegister();
subRetryAt_ = 0; // a new registration: its subscription starts afresh
OnRegistered();
Log("recovered by a fresh registration");
} catch (const ThrottledError& e) {
@ -1712,23 +1725,156 @@ private:
// subscription.
void OnRegistered() {
recovery_.OnSuccess();
closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before
KeepHolds();
bool was = registered_;
registered_ = true;
EmitStatus(!was);
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();
}
// (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh
// dialog each time, 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() {
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");
else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); }
// The network granted the subscription `expires` seconds (a SUBSCRIBE
// 2xx, a NOTIFY's Subscription-State): subscribe anew 10 min before the
// end, or halfway through a shorter grant.
void ArmSubscriptionRenewal(long expires) {
double ahead = expires > 1200 ? expires - 600.0 : expires / 2.0;
subRenewAt_ = Mono() + std::max(ahead, 30.0);
Log(std::format("reg-event: subscription granted {} s; renewed in {:.0f} s", expires, subRenewAt_ - Mono()));
}
// 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 -------------------------------------------------
@ -1762,7 +1908,9 @@ private:
// recovery path one immediate shot first: user action cuts the
// backoff short, never a wait the network imposed (a
// 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
// same rule again.
if (!sip_.Alive()) {
@ -1813,7 +1961,12 @@ private:
? std::string_view{}
: std::string_view(msg).substr(at + 4);
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));
return;
}
@ -1894,7 +2047,13 @@ private:
}
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) {

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; }

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

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;

View file

@ -241,6 +241,53 @@ int main() {
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\">&quot;&lt;urn:gsma:imei:35901961-000000-0&gt;&quot; </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");
return Failures;
}