From 142adf057c417f4996428a3820d3a665ceff4057 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Thu, 24 Sep 2026 18:56:34 +0200 Subject: [PATCH] engine: register fresh when the refresh is refused or nothing answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After bring-up the daemon recovered a dead protected flow by reconnecting through the existing SA and refreshing, on a 5 s → 5 min backoff, forever. That was the whole recovery: a refresh the network refused, or a P-CSCF that stopped answering, kept it in that loop on the same Call-ID and SA until someone restarted the service. O2 UK, 2026-09-24: the 30-minute refresh answered 503, 21 refusals over 70 min; after a manual restart the new registration's flow died 8 s in and no connect over its SA succeeded for 2 h 39 min; a second restart registered within two seconds (13:27:55 → 13:27:57 in the journal; ims ledger F16). Do in place the rest of what a restart does: when the refresh keeps being refused, or reconnect cycles keep ending without an answer unless the modem reports no packet service, register fresh. The fresh registration is the expensive step, so a failed one waits before the next: on a schedule (21 min, growing to 60) after a refusal or a throttle, on a backoff after silence, and a Retry-After from the network is honoured up to an hour. What the network set — a Retry-After, the schedule after a refused or throttled attempt — is kept across a restart in /run/imsd.holds, for the address it was set on. A refused initial registration at bring-up — a final from 300 up to the unprotected REGISTER (its 401 is the challenge), any final but 200 to the protected one, a second 401 (rejected credentials) included — now waits on the same schedule instead of exiting for a systemd restart every two minutes. The policy is a pure reducer in Imsd:Engine (RegRecovery), shaped like the call machine: the daemon supplies the clock and the network, the reducer holds the deadlines and names the next step, and tests/Engine pin it. tests/Engine now exits 0 or 1 instead of its failure count: with more than 255 checks a count of 256 would exit 0, and crafter-build reads 77 as a skipped suite. The bring-up's deferral is logged in the policy's form: `bring-up deferred: — attempt N, next fresh registration in M min` becomes `bring-up deferred: ; next: fresh registration in M min`. A bring-up writes the verdict that the old registration is gone as well: a start that goes on to register fresh — its resume failed, or there was nothing to resume over — records it before the attempt, and when the attempt then fails without an answer the daemon exits with the file in place. The next start on the same address and boot does not resume, where 0.3.9 retried the resume: it registers fresh at once. On KPN, with the old registration in fact still live — a start during an outage, staged on the dev phone — that attempt was throttled, and the 21-min schedule began. A bring-up does not retry its resume through an outage the way recovery reconnects. Tested on the dev phone (KPN) in builds of this series; not every path has run there. --- README.md | 30 +++ implementations/main.cpp | 493 ++++++++++++++++++++++++++++++------ interfaces/Imsd-Engine.cppm | 179 ++++++++++++- tests/Engine/main.cpp | 191 +++++++++++++- 4 files changed, 806 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 49a9176..e05617b 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ PCSCF=2001:db8::105 | `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` | | `USER_AGENT` | `imsd/` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | +| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule) and where that schedule stands, kept across a restart of imsd for the address they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | @@ -289,6 +290,35 @@ The protected client/server ports are fixed at 45061/45062 (`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires updating the firewall rules `ims-pdn-up.sh` installs. +**When the registration stops working** (the refresh is refused, or the +protected flow dies and nothing answers the reconnect) imsd does in place +what a restart does: it reconnects through the existing SA and refreshes; +after two refused refreshes or two unanswered reconnect cycles (such a +cycle is not counted while the modem reports no packet service) it +registers fresh (new Call-ID, new challenge, new SA pair). Reconnects back +off 5 s → 5 min; a refused or throttled fresh attempt waits on the throttle +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` +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 +refused or throttled fresh attempt); imsd-ofonod refuses a `Dial` then, and +imsd-dialerd lists no modem. A bring-up the network deferred waits the same +schedule out with nothing to reconnect over (the resume just found the +stored SAs unusable), then tries again. The network's holds outlive a +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. +The journal says which (`…; next: fresh registration in N s`, `registering +fresh (recovery attempt N)`, `fresh registration deferred/refused/failed: +…`, `bring-up deferred: …`). + ### imsd-media (per-call, set through imsd's environment) | Variable | Default | Meaning | diff --git a/implementations/main.cpp b/implementations/main.cpp index 40d3eea..28819e5 100644 --- a/implementations/main.cpp +++ b/implementations/main.cpp @@ -33,6 +33,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live #include #include #include +#include #include import std; @@ -188,11 +189,19 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U // connect (O2 UK, 2026-09-17). Both run through the same SA pair — the xfrm // policies select on ports only — and the same Via port. // A P-CSCF refusing a fresh security association (KPN: Security-Server -// spi-s=0 to a fresh REGISTER made too soon after another; the window is -// ~20 min and every fresh attempt re-arms it). Handled in-process: a +// spi-s=0 to a fresh REGISTER on an address whose registration is still +// live). Handled in-process: a // systemd restart every RestartSec would never clear it. struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; -constexpr int ThrottleWaitMin = 21; +// A REGISTER the network refused: the protected one, or a refresh, answered +// with a final other than 200 (a refresh's first 401 is its challenge); the +// unprotected initial one answered with a final from 300 up (its 401 is the +// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. +struct RefusedError : std::runtime_error { + int status; + long retryAfter; + RefusedError(const std::string& what, int st, long ra) : std::runtime_error(what), status(st), retryAfter(ra) {} +}; // Bytes of SIP that fit one ESP-protected IPv6 packet at the ims PDN's // 1280-byte MTU (40 IPv6 + 8 ESP + 16 IV + 8 UDP + padding + 12 ICV). constexpr std::size_t UdpSinglePacketBudget = 1190; @@ -211,24 +220,30 @@ public: // one attempt. bool Connect(const std::string& local, int lport, const std::string& pcscf, int pport, Transport t, int tries = 10, int silentLimit = 0) { transport_ = t; + lastConnectLocal_ = false; int fam = Is6(local) ? AF_INET6 : AF_INET; if (t == Transport::Udp) { int fd = socket(fam, SOCK_DGRAM, 0); - if (fd < 0) return false; + if (fd < 0) return SocketFailed(); int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + if (!Bind(fd, la, ll, local, lport)) return false; sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); - if (connect(fd, reinterpret_cast(&pa), pl) != 0) { Log(std::format("UDP connect: {}", std::strerror(errno))); close(fd); return false; } + if (connect(fd, reinterpret_cast(&pa), pl) != 0) { + // a UDP connect() cannot be refused by the peer: no route + // yet, and the like — local, like the TCP path's ENETUNREACH + Log(std::format("UDP connect: {}", std::strerror(errno))); + close(fd); lastConnectLocal_ = true; return false; + } fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false; return true; } int silent = 0; for (int attempt = 0; attempt < tries; attempt++) { int fd = socket(fam, SOCK_STREAM, 0); - if (fd < 0) return false; + if (fd < 0) return SocketFailed(); int one = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); // REUSEPORT (paired with the listener's): a reconnect must bind @@ -236,7 +251,7 @@ public: // it in LISTEN state — REUSEADDR alone only covers TIME_WAIT. setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la); - if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + if (!Bind(fd, la, ll, local, lport)) return false; sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); // non-blocking connect + poll: Linux connect() ignores SO_SNDTIMEO, // so bound it ourselves (a silent P-CSCF must not hang the engine). @@ -259,21 +274,47 @@ public: return true; } close(fd); + lastConnectLocal_ = localRefusal; if (!localRefusal) silent++; - if (attempt == tries - 1) return false; - if (silentLimit > 0 && silent >= silentLimit) { - Log(std::format("connect: no answer from the P-CSCF {} times", silent)); - return false; - } // An answer (RST → ECONNREFUSED, ICMP → EHOSTUNREACH/ENETUNREACH) // counts towards the silent limit like a timeout: either way the // P-CSCF does not serve TCP on that port. The log says which. const char* why = err == ETIMEDOUT ? "no answer" : (err == EADDRNOTAVAIL ? "TIME_WAIT?" : std::strerror(err)); + if (attempt == tries - 1) { + Log(std::format("connect failed ({}) [{}/{}]", why, attempt + 1, tries)); + return false; + } + if (silentLimit > 0 && silent >= silentLimit) { + Log(std::format("connect: no answer from the P-CSCF {} times ({})", silent, why)); + return false; + } Log(std::format("connect failed; retry in 10s ({}) [{}/{}]", why, attempt + 1, tries)); sleep(10); } return false; } + // socket() failing is ours, not the network's: logged, and a local + // refusal like a connect() the kernel turns down. + bool SocketFailed() { + Log(std::format("connect failed: socket: {}", std::strerror(errno))); + lastConnectLocal_ = true; + return false; + } + // A bind the kernel refuses (the address is not on the link: + // EADDRNOTAVAIL) is logged and counts like the network's silence — it is + // not a transient refusal to retry every 5 s. Closes fd on failure. + bool Bind(int fd, const sockaddr_storage& la, socklen_t ll, const std::string& local, int lport) { + if (bind(fd, reinterpret_cast(&la), ll) == 0) return true; + int err = errno; + close(fd); + lastConnectLocal_ = false; + Log(std::format("connect failed: cannot bind {} ({})", imsd::util::HostPort(local, lport), std::strerror(err))); + return false; + } + // Whether the last failed Connect() ended on a local refusal (our own + // 4-tuple still in TIME_WAIT, no route yet) rather than on the network's + // silence or answer — the recovery policy counts only the latter. + bool LastConnectLocal() const { return lastConnectLocal_; } // MSG_NOSIGNAL: a flow the network killed (CSFB excursion, P-CSCF idle // drop) must surface as a send error, not a SIGPIPE. bool Send(std::string_view msg) { @@ -337,10 +378,13 @@ public: int Fd() const { return fd_; } bool Alive() const { return fd_ >= 0 && alive_; } Transport CurrentTransport() const { return transport_; } - void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; } + // What was read and not yet dispatched goes with the flow: nothing + // read from it can be answered once it is closed. + void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; fb_ = {}; } private: int fd_ = -1; bool alive_ = false; + bool lastConnectLocal_ = false; Transport transport_ = Transport::Tcp; bool sizeWarned_ = false; imsd::sip::FrameBuffer fb_; @@ -589,6 +633,87 @@ std::int64_t NowEpoch() { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } +// 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 +// 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 +// clock: no clock step moves it, and it runs through a suspend as the +// network's timers do. Whether the old registration is gone travels with +// them: a restart inside a hold must not resume over SAs the network has +// written off. +std::int64_t BootSeconds() { + timespec ts{}; + clock_gettime(CLOCK_BOOTTIME, &ts); + return ts.tv_sec; +} + +std::string BootId() { + std::string id; + if (std::FILE* f = std::fopen("/proc/sys/kernel/random/boot_id", "r")) { + char buf[64]; + id.assign(buf, std::fread(buf, 1, sizeof buf, f)); + std::fclose(f); + } + while (!id.empty() && std::isspace(static_cast(id.back()))) id.pop_back(); + return id; +} + +// The recovery policy runs on the same clock: a hold in the process and +// its copy in the file age alike, through a suspend as the network's +// timers do. +double Boot() { + timespec ts{}; + clock_gettime(CLOCK_BOOTTIME, &ts); + return static_cast(ts.tv_sec) + static_cast(ts.tv_nsec) / 1e9; +} + +std::string HoldsPath() { + if (const char* o = std::getenv("HOLDS_FILE")) return o; + if (geteuid() == 0) return "/run/imsd.holds"; + return std::format("{}/imsd.holds", EnvOr("XDG_RUNTIME_DIR", "/tmp")); +} + +void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& pdn) { + std::string path = HoldsPath(); + if (!h.Any()) { + // nothing to keep: the file goes — when it is ours. A device node or a + // link the HOLDS_FILE knob points at (/dev/null switches the file off) is + // emptied instead, never unlinked. + std::error_code ec; + auto st = std::filesystem::symlink_status(path, ec); + if (std::filesystem::is_regular_file(st)) std::remove(path.c_str()); + else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); } + return; + } + std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", + pdn, BootId(), BootSeconds(), static_cast(std::ceil(h.hold)), static_cast(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); + if (std::FILE* f = std::fopen(path.c_str(), "w")) { + std::fwrite(j.data(), 1, j.size(), f); + std::fclose(f); + } +} + +std::optional LoadHolds(const std::string& pdn) { + std::FILE* f = std::fopen(HoldsPath().c_str(), "r"); + if (!f) return std::nullopt; + std::string s; + char buf[512]; std::size_t n; + while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); + std::fclose(f); + if (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt; + if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt; + long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0); + imsd::engine::RegRecovery::Held h; + h.hold = std::max(0, IntAfter(s, "\"hold\": ").value_or(0) - elapsed); + h.freshHold = std::max(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); + h.throttleMinutes = static_cast(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); + h.oldGone = s.find("\"old_gone\": true") != std::string::npos; + if (!h.Any()) return std::nullopt; + return h; +} + // build an a{sv} from a CallInfo (matches imsd.py _d typing) GVariant* CallVariant(const CallInfo& c) { GVariantBuilder b; @@ -723,28 +848,31 @@ public: } void Run() { + if (auto h = LoadHolds(local_)) { + recovery_.Restore(Boot(), *h); + auto kept = recovery_.Holds(Boot()); // what the policy took over: the file's values after the clamp + if (kept.hold > 0 || kept.freshHold > 0) + Log(std::format("a hold the network set before this start stands ({:.0f} s for any attempt, {:.0f} s for a fresh registration; the refusal schedule at {} min)", kept.hold, kept.freshHold, kept.throttleMinutes)); + else if (kept.throttleMinutes > imsd::engine::RegRecovery::ThrottleMinutesMin) + Log(std::format("the refusal schedule stands at {} min from before this start: a refused or throttled fresh registration waits that long", kept.throttleMinutes)); + if (kept.oldGone) Log("the old registration was gone before this start: no resume over its SAs, the fresh registration once its wait has run"); + } for (;;) { try { BringUp(); break; + } catch (const Stopping&) { + return; } catch (const ThrottledError& e) { - // The window grows on repeats (a carrier's window may exceed the - // first guess and every throttled attempt re-arms it); it resets - // after a successful bring-up. - int waitMin = throttleWaitMin_; - throttleCount_++; - Log(std::format("bring-up deferred: {} — attempt {}, next fresh registration in {} min", e.what(), throttleCount_, waitMin)); - registered_ = false; - EmitStatus(true); - for (int i = 0; i < waitMin * 60 && !quit_.load(); i++) { - // A Dial/Accept/HangUp arriving now must not wait in the - // queue and run stale once registration succeeds; with no - // flow the call collapses to terminated at once (DevLoop's rule). - if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } - std::this_thread::sleep_for(std::chrono::seconds(1)); - } + DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Throttled, 0); + if (quit_.load()) return; + continue; + } catch (const RefusedError& e) { + // A refused initial registration (403, 5xx) waits like a + // throttle: a fatal would restart imsd every RestartSec with an + // initial REGISTER each time. + DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter); if (quit_.load()) return; - throttleWaitMin_ = std::min(throttleWaitMin_ * 3 / 2, 60); continue; } catch (const std::exception& e) { if (devMode_) { @@ -763,14 +891,9 @@ public: return; } } - throttleWaitMin_ = ThrottleWaitMin; throttleCount_ = 0; - registered_ = true; - EmitStatus(true); - lastRefresh_ = Mono(); - lastKa_ = Mono(); try { server_.Open(local_); } catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); } - SubscribeRegEvent(); + OnRegistered(); while (!quit_.load()) { if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; } @@ -813,6 +936,41 @@ public: private: struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; + struct Stopping {}; // the engine was told to stop while a bring-up waited + + // A bring-up the network deferred (a fresh-SA throttle) or refused: wait + // it out here, in process, on the recovery policy's schedule, then try + // the bring-up again. There is nothing to reconnect over meanwhile (the + // resume just found the stored SAs unusable, or there are none) and no + // recovery step runs from inside the wait — portPs_ says so to + // MaybeReconnect, whatever the attempt left in it — so a Dial + // meanwhile collapses at once, as in dev mode. + void DeferBringUp(std::string_view why, imsd::engine::RegRecovery::Failure f, long retryAfter) { + FreshAttemptFailed(f, retryAfter, std::format("bring-up deferred: {}", why)); + portPs_ = 0; + registered_ = false; + EmitStatus(true); + WaitForPolicy(); + } + + // Wait until the policy's next step is due. A Dial/Accept/HangUp + // arriving meanwhile must not wait in the queue and run stale once + // registration succeeds; with no flow the call collapses to terminated + // at once (DevLoop's rule). A wait can be long (an hour on the + // schedule), so the REGISTER after one names the access network read + // after it, not before. False when the engine is stopping. + bool WaitForPolicy() { + bool waited = false; + while (!quit_.load() && recovery_.Next(Boot()).at > Boot()) { + waited = true; + if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } + std::this_thread::sleep_for(std::chrono::seconds(1)); + } + if (waited && !quit_.load()) RefreshPani(); + return !quit_.load(); + } + + void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), local_); } // dev/session mode without a modem: process commands so the ABI answers, // but there is no SIP socket — a Dial collapses to terminated/error. @@ -826,8 +984,6 @@ private: // config std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp - int throttleWaitMin_ = ThrottleWaitMin; // grows on consecutive throttles, resets on success - int throttleCount_ = 0; Transport transport_ = Transport::Tcp; // the protected leg's transport in effect void SetTransport(Transport t) { transport_ = t; ctx_.transport = std::string(TransportName(t)); } std::vector emergencyExtra_; @@ -845,8 +1001,8 @@ private: ServerPorts server_; std::string subCallid_; // live reg-event subscription dialog (empty: none) int portPs_ = 0; // P-CSCF protected server port (reconnect target) - double nextReconnect_ = 0; - double reconnectDelay_ = 5; + 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) // call imsd::util::Rng rng_; @@ -935,10 +1091,31 @@ private: // registered fresh for the same IMPI, a stale binding, …) is not // fatal: the fresh registration below replaces its SAs. Only a // failed FRESH registration is. + // 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. if (resume != "0") { - try { if (TryResume()) return; } - catch (const std::exception& e) { Log(std::format("resume failed: {} — registering fresh", e.what())); CloseFlowAfterWait(); } + if (!WaitForPolicy()) throw Stopping{}; + // a registration the network wrote off before this start (the + // holds carry it) is not resumed: the wait above was the fresh one + try { if (recovery_.Reconnectable() && TryResume()) return; } + catch (const RefusedError& e) { + Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); + CloseFlowAfterWait(); + if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); + } + catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } } + // a resume that failed found the stored SAs unusable (and after a + // reboot there are none): nothing to reconnect over while a deferred + // fresh registration waits + recovery_.OldGone(); + portPs_ = 0; + KeepHolds(); + if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); + if (!WaitForPolicy()) throw Stopping{}; FreshRegister(); } @@ -1005,14 +1182,24 @@ private: Log(std::format("binding: {}", ct)); } + // A fresh registration. The attempt mints its own Contact user-part into + // ctx_ for its REGISTERs; on any failure the stored binding's comes back, + // so requests over the existing SA meanwhile (a reconnect, a refresh) + // carry the binding the network knows. reg_ takes the new one on the 200. void FreshRegister() { + saReplaced_ = false; + try { FreshRegisterAttempt(); } + catch (...) { ctx_.contactUser = reg_.contactUser; throw; } + } + + void FreshRegisterAttempt() { Log("FRESH registration"); // Oracle diff (journal/ims.md s53): stock's Contact user-part is a // fresh UUID per registration, not the IMSI. Minted before reg1 so // both REGISTERs and the stored binding carry the same URI. - reg_.contactUser = imsd::util::Uuid4(rng_); - ctx_.contactUser = reg_.contactUser; - Log(std::format("contact user-part: {}", reg_.contactUser)); + std::string contactUser = imsd::util::Uuid4(rng_); + ctx_.contactUser = contactUser; + Log(std::format("contact user-part: {}", contactUser)); std::string callidReg = std::format("{}@{}", rng_.Token(16), local_); std::string ftag = rng_.Token(8); std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF); @@ -1031,9 +1218,11 @@ private: sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa); sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast(&pa), pl); std::string resp = AwaitInitialFinal(us, callidReg); - if (imsd::sip::Status(resp) != 401) { + if (auto st1 = imsd::sip::Status(resp); st1 != 401) { DumpRaw("imsd-register-fail.raw", resp); - throw std::runtime_error(std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp))); + std::string what = std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp)); + if (st1 && *st1 >= 300) throw RefusedError(what, *st1, imsd::sip::RetryAfter(resp).value_or(0)); + throw std::runtime_error(what); } auto nonceB64 = QuotedAfter(resp, "nonce=\""); if (!nonceB64) throw std::runtime_error("no nonce in 401"); @@ -1088,6 +1277,7 @@ private: sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.alg = alg; sp.ealg = ealg; + saReplaced_ = true; // from here on the old registration's SAs are gone for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { int rc = RunCmd(cmd); if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc)); @@ -1120,7 +1310,7 @@ private: // pair (the SA setup flushes this one), same ports. Log("protected TCP connect unanswered; registering fresh over UDP"); SetTransport(Transport::Udp); - FreshRegister(); + FreshRegisterAttempt(); return; } throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_))); @@ -1135,9 +1325,10 @@ private: std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER"); if (auto st = imsd::sip::Status(ok); st != 200) { DumpRaw("imsd-register-fail.raw", ok); - throw std::runtime_error(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok))); + throw RefusedError(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok)), *st, imsd::sip::RetryAfter(ok).value_or(0)); } reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; + reg_.contactUser = contactUser; reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); DumpRaw("imsd-register-200.raw", ok); @@ -1255,7 +1446,7 @@ private: continue; } DumpRaw("imsd-register-fail.raw", m); - throw std::runtime_error(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m))); + throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); } } @@ -1344,13 +1535,19 @@ private: if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); + OnRegistered(); + } catch (const RefusedError& e) { + // The network refused the refresh: the recovery policy counts it + // (a second refusal means a fresh registration) and the flow is + // dropped so the reconnect step retries the refresh after the + // wait (Retry-After when given). + Log(std::format("keepalive re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); + RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); } catch (const std::exception& e) { - // Whatever failed (dead flow, silent P-CSCF, error status), the - // recovery is the same as a daemon restart: drop the flow and - // let MaybeReconnect re-run the connect + true-refresh sequence. + // A dead flow or a silent P-CSCF: the recovery is the same as a + // daemon restart's — drop the flow and let MaybeReconnect re-run + // the connect + true-refresh sequence at once. Log(std::format("keepalive re-REGISTER failed: {}", e.what())); CloseFlowAfterWait(); } @@ -1362,39 +1559,165 @@ private: // registration stay valid — a CSFB excursion resets it, the P-CSCF drops // idle flows — which is exactly the state a daemon restart resumes from // (s51: "restart-to-recover"). Do what the restart does, in place: - // reconnect the TCP leg over the existing SA and true-refresh. Backoff - // doubles 5 s → 5 min so a dead PDN doesn't turn this into a hot loop. + // reconnect the TCP leg over the existing SA and true-refresh. When that + // keeps failing — the network refuses the refresh, or nothing answers — + // do the rest of what a restart does: register fresh. RegRecovery holds + // the thresholds and the pacing (a dead PDN must not turn this into a + // hot loop; a carrier that refuses fresh SAs must not be hammered). void MaybeReconnect() { if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return; - if (Mono() < nextReconnect_) return; + auto plan = recovery_.Next(Boot()); + if (plan.at > Boot()) return; + if (plan.step == imsd::engine::RegRecovery::Step::Fresh) FreshRecover(); + else ReconnectCycle(); + } + + // The policy's next step, as the journal line: what, and when. + std::string NextStep() { + auto plan = recovery_.Next(Boot()); + double in = plan.at - Boot(); + std::string what = plan.step == imsd::engine::RegRecovery::Step::Fresh ? "fresh registration" : "reconnect over the existing SA"; + if (in <= 0) return std::format("next: {} now", what); + if (in >= 60) return std::format("next: {} in {:.0f} min", what, in / 60); + return std::format("next: {} in {:.0f} s", what, in); + } + + // ModemManager's word on the phone's own service, as ims-pdn-up.sh reads + // it (`mmcli -K`). False only when the modem clearly has none: a state + // short of registered, or packet service detached. Anything else — + // ModemManager not answering, a state or packet-service value this + // code does not know — counts as service: the network's silence, not + // the phone's, is the default. + bool PacketServiceUp() { + auto r = RunCapture({"mmcli", "-m", "any", "-K"}); + auto field = [&](std::string_view key) -> std::string { + std::size_t at = r.out.find(key); + if (at == std::string::npos) return {}; + std::size_t end = r.out.find('\n', at); + if (end == std::string::npos) end = r.out.size(); + std::size_t colon = r.out.find(':', at); + if (colon == std::string::npos || colon > end) return {}; + std::string v = r.out.substr(colon + 1, end - colon - 1); + while (!v.empty() && v.front() == ' ') v.erase(v.begin()); + while (!v.empty() && (v.back() == ' ' || v.back() == '\r')) v.pop_back(); + return v; + }; + std::string state = field("modem.generic.state "), ps = field("modem.3gpp.packet-service-state "); + for (const char* down : {"failed", "initializing", "locked", "disabled", "disabling", "enabling", "enabled", "searching"}) + if (state == down) return false; + return ps != "detached"; + } + + // A cycle that failed while the modem reports no packet service is the + // phone's silence, not the network's: not counted, retried on the + // local-refusal pace like a connect() the kernel refused — a radio gap + // across a refresh must not escalate to a fresh registration, which KPN + // refuses while the old one is live, and drops it for. + void CoverageGap(std::string_view what) { + if (registered_) { registered_ = false; EmitStatus(true); } + recovery_.OnLocalRefusal(Boot()); + Log(std::format("{} while the modem reports no packet service: not counted; {}", what, NextStep())); + } + + void ReconnectCycle() { + using F = imsd::engine::RegRecovery::Failure; Log("client flow dead; reconnecting"); sip_.Close(); - bool ok = sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2); - if (ok) { - try { - RefreshPani(); - auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); - reg_.cseq = used; - if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; - UpdateRouteAndPpi(msg); - LogBindings(msg); - PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); - Log("reconnected + re-registered"); - SubscribeRegEvent(); - if (!registered_) { registered_ = true; EmitStatus(true); } - else EmitStatus(false); - lastRefresh_ = Mono(); lastKa_ = Mono(); - reconnectDelay_ = 5; - nextReconnect_ = 0; + if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) { + if (sip_.LastConnectLocal()) { + if (registered_) { registered_ = false; EmitStatus(true); } + recovery_.OnLocalRefusal(Boot()); return; - } catch (const std::exception& e) { - Log(std::format("reconnect re-REGISTER failed: {}", e.what())); - CloseFlowAfterWait(); } + if (!PacketServiceUp()) { CoverageGap("P-CSCF silent"); return; } + RecoveryFailed(F::Silent, 0, "P-CSCF silent"); + return; } + try { + RefreshPani(); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; + UpdateRouteAndPpi(msg); + LogBindings(msg); + PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); + Log("reconnected + re-registered"); + OnRegistered(); + } catch (const RefusedError& e) { + Log(std::format("reconnect re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); + RecoveryFailed(F::Refused, e.retryAfter, "refresh refused", e.status); + } catch (const std::exception& e) { + Log(std::format("reconnect re-REGISTER failed: {}", e.what())); + CloseFlowAfterWait(); + if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; } + RecoveryFailed(F::Silent, 0, "refresh unanswered"); + } + } + + // A failed reconnect + refresh cycle: the status goes unregistered, the + // policy decides whether the next step is another cycle or a fresh + // registration, and when. + void RecoveryFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, std::string_view what, int status = 0) { if (registered_) { registered_ = false; EmitStatus(true); } - nextReconnect_ = Mono() + reconnectDelay_; - reconnectDelay_ = std::min(reconnectDelay_ * 2, 300.0); + bool wasReconnectable = recovery_.Reconnectable(); + recovery_.OnReconnectFailure(Boot(), f, retryAfter, status); + KeepHolds(); + Log(std::format("{} ({} refused, {} silent cycles){}; {}", what, recovery_.refusals, recovery_.silentCycles, + wasReconnectable && !recovery_.Reconnectable() ? std::format(" — the old registration is gone ({})", status) : "", NextStep())); + } + + // The rest of what a restart does, in place: a new Call-ID, a new + // challenge, a new SA pair (FreshRegister flushes the old ones), then + // the post-registration steps. A refused or throttled attempt waits on + // the throttle schedule (a wait no Dial cuts short — on KPN a refused + // attempt costs the old registration) and a silent one on the + // short backoff. The attempt's flow is closed whichever way it failed; + // past the SA setup the old registration's SAs are gone with it, so + // nothing is retried until the next fresh attempt. + void FreshRecover() { + using F = imsd::engine::RegRecovery::Failure; + Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1)); + sip_.Close(); + saReplaced_ = false; // this attempt's, whatever fails first + subCallid_.clear(); + try { + RefreshPani(); + FreshRegister(); + OnRegistered(); + Log("recovered by a fresh registration"); + } catch (const ThrottledError& e) { + FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what())); + } catch (const RefusedError& e) { + FreshAttemptFailed(F::Refused, e.retryAfter, std::format("fresh registration refused: {}", e.what())); + } catch (const std::exception& e) { + FreshAttemptFailed(F::Silent, 0, std::format("fresh registration failed: {}", e.what())); + } + } + + // After a failed fresh attempt, in recovery or at bring-up: the + // attempt's flow closed (a refused protected REGISTER leaves it open), + // the policy told, and its next step logged. + void FreshAttemptFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, const std::string& what) { + CloseFlowAfterWait(); + bool wasReconnectable = recovery_.Reconnectable(); + recovery_.OnFreshFailure(Boot(), f, retryAfter, saReplaced_); + KeepHolds(); + Log(std::format("{}{}; {}", what, wasReconnectable && !recovery_.Reconnectable() ? " — the old registration is gone" : "", NextStep())); + } + + // After a registration succeeds — bring-up, a keepalive refresh, a + // reconnect + refresh, or a fresh recovery: the status (a signal only + // when it changes), the timers, the recovery policy, the reg-event + // subscription. + void OnRegistered() { + recovery_.OnSuccess(); + KeepHolds(); + bool was = registered_; + registered_ = true; + EmitStatus(!was); + lastRefresh_ = Mono(); lastKa_ = Mono(); + SubscribeRegEvent(); } // (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh @@ -1436,12 +1759,14 @@ private: bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); if (!ClearForDial(c, emergency)) return; // a dead client flow fails the INVITE instantly — give the - // reconnect path one immediate shot first (user action beats - // the backoff timer). The shot's REGISTER wait dispatches + // 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 // requests, so an incoming call may have arrived under it: the // same rule again. if (!sip_.Alive()) { - nextReconnect_ = 0; MaybeReconnect(); + recovery_.OnDial(); MaybeReconnect(); if (!ClearForDial(c, emergency)) return; } RefreshPani(); diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm index 7b7805e..a76dffe 100644 --- a/interfaces/Imsd-Engine.cppm +++ b/interfaces/Imsd-Engine.cppm @@ -3,7 +3,9 @@ // lint-disable-file fixed-width-types /* -Imsd:Engine — the call state machine as a pure reducer. +Imsd:Engine — the daemon's decisions as pure reducers: the call state +machine (CallMachine) and the registration recovery policy (RegRecovery, +its own section below). A CallMachine owns one call's SIP dialog, outgoing or incoming. It never touches a socket, a clock, or a subprocess: each event (the INVITE's @@ -52,6 +54,181 @@ export namespace imsd::engine { return d == Direction::Incoming ? "incoming" : "outgoing"; } + // ---- registration recovery policy --------------------------------- + // + // What the daemon does when the registration stops working after + // bring-up, as a pure reducer: the shell supplies the clock and the + // network, this decides the next step and how long to wait for it. Two + // kinds of failure reach it. A REFUSAL: the network answered a refresh + // (or a fresh REGISTER) with a final other than 200/401. SILENCE: the + // flow died and reconnecting through the SA, or the refresh over it, + // got no answer. Neither is retried for long on the same registration. + // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after + // a 408, 500, 504 or 403 on a re-REGISTER (5.1.1.2.1: not before a + // Retry-After has passed), and RFC 5626 has it register again when its + // flow cannot be recovered. imsd does so after any refusal: the 503s + // below are not in that list. Before this reducer the daemon looped + // reconnect + re-REGISTER on the same Call-ID and SA forever — O2 UK, + // 2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect + // cycles over 2 h 39 min, each ended only by a manual restart whose + // fresh registration the network accepted within two seconds (ims + // ledger F16). + // + // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates + // to a fresh registration after two (one 503 may be a momentary + // hiccup a retried refresh clears), silence after two reconnect cycles + // that end without an answer — a TCP connect series whose last attempt + // was not a local refusal, a bind() the kernel refused, or a refresh + // that fails short of a refusal: unanswered, an unusable challenge, a + // failed send (the resume at start gives up after two unanswered + // connects). + // A fresh registration is the expensive step: an AKA run, a + // new SA pair, and on KPN a refusal (spi-s=0) while the registration on + // the address is live, which costs that registration. So a refused or + // throttled fresh attempt + // waits on the throttle schedule (21 min, growing ×1.5 to 60) and a + // silent one backs off 30 s → 10 min. Retry-After (RFC 3261 20.33) + // always wins over a shorter wait. A 403 is a refusal like the others: + // never hammered (the schedule), never abandoned (a SIM that is + // 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 + // 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 + // 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 + // step (Next). Success resets everything. + struct RegRecovery { + enum class Failure { Refused, Silent, Throttled }; + enum class Step { Reconnect, Fresh }; + // What comes next: the step, and the earliest time it may run (the + // shell's clock; at <= now means now). + struct Plan { Step step; double at; }; + + static constexpr int RefusalsBeforeFresh = 2; + static constexpr int SilentCyclesBeforeFresh = 2; + static constexpr double ReconnectDelayMin = 5; + static constexpr double ReconnectDelayMax = 300; + static constexpr double FreshDelayMin = 30; + static constexpr double FreshDelayMax = 600; + static constexpr int ThrottleMinutesMin = 21; + static constexpr int ThrottleMinutesMax = 60; + // The network's word is honoured up to the schedule's own longest + // wait: a Retry-After beyond an hour is cut to it, and the refusal + // heard again then — one REGISTER an hour at most, never a day + // unregistered on one answer. + static constexpr long RetryAfterMax = ThrottleMinutesMax * 60; + // A connect refused locally — our own 4-tuple still in TIME_WAIT + // (up to 60 s after we closed it), no route yet — is not the + // network's answer: retried soon, never counted, the backoff + // untouched. Dev phone 2026-09-24: four such refusals in 26 s read + // as two silent cycles cost a fresh registration into the throttle. + static constexpr double LocalRetry = 5; + + int refusals = 0; + int silentCycles = 0; + int freshAttempts = 0; + double reconnectDelay = ReconnectDelayMin; + double freshDelay = FreshDelayMin; + 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 + // 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) + + bool Fresh() const { return fresh; } + bool Reconnectable() const { return !oldGone; } + + // The network's holds as seconds still to run, and where the + // schedule stands: what a restart of the process must not forget. + struct Held { + double hold = 0; + double freshHold = 0; + int throttleMinutes = ThrottleMinutesMin; + bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over + // a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict + bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } + }; + Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } + // A restarted process takes them over; nothing here shortens a + // hold it already has, or lengthens one past RetryAfterMax. + void Restore(double now, const Held& h) { + if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast(RetryAfterMax))); + if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast(RetryAfterMax))); + throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); + if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off + } + + // The next step and when. Not escalated: a reconnect after the + // backoff and any hold. Escalated: the fresh attempt once the + // backoff, any hold and the fresh hold have passed — and while a + // fresh hold still runs, a reconnect over the old SA meanwhile, the + // cheap thing, unless that registration is gone. + Plan Next(double now) const { + double any = std::max(backoffUntil, holdUntil); + double freshAt = std::max(any, freshHoldUntil); + if (!fresh) return {Step::Reconnect, any}; + if (!oldGone && freshHoldUntil > any && now < freshHoldUntil) return {Step::Reconnect, any}; + return {Step::Fresh, freshAt}; + } + + void OnLocalRefusal(double now) { backoffUntil = now + LocalRetry; } + + // A refused keepalive refresh, or a failed reconnect + refresh + // cycle; `status` is the refusal's. The backoff paces the next step; + // a Retry-After holds every attempt for its own length (up to + // RetryAfterMax), whichever is longer. A 403 once a fresh + // 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 (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(std::min(retryAfter, RetryAfterMax))); + } + + // A failed fresh registration; `saReplaced`: it got past the SA + // setup, so the old registration's SAs went with it. Refusals and + // throttles hold the next fresh attempt on the schedule (a Retry-After + // replaces it, never below the short backoff's floor nor above + // RetryAfterMax); silence backs + // it off 30 s → 10 min. A reconnect over the old SA meanwhile waits + // at least the reconnect backoff. + void OnFreshFailure(double now, Failure f, long retryAfter = 0, bool saReplaced = false) { + freshAttempts++; + fresh = true; + if (saReplaced) oldGone = true; + backoffUntil = std::max(backoffUntil, now + reconnectDelay); + if (f == Failure::Silent) { + backoffUntil = std::max(backoffUntil, now + freshDelay); + freshDelay = std::min(freshDelay * 2, FreshDelayMax); + return; + } + double wait = throttleMinutes * 60.0; + throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); + if (retryAfter > 0) wait = std::max(static_cast(std::min(retryAfter, RetryAfterMax)), FreshDelayMin); + freshHoldUntil = now + wait; + } + + // 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; } + + // A user's Dial cuts the backoff short, never the network's word. + void OnDial() { backoffUntil = 0; } + + void OnSuccess() { *this = RegRecovery{}; } + }; + // Parsed SDP answer needed to bring up the media leg. `codec` is one of // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). struct MediaLeg { diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp index 284186e..be18212 100644 --- a/tests/Engine/main.cpp +++ b/tests/Engine/main.cpp @@ -11,7 +11,11 @@ // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // what was offered), a landline gateway's G.711-only and AMR-NB offers // ring and are answered at the offer's own payload types, ring timeout -> -// 480, BYE both ways. Pure reducer, no I/O. +// 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. import std; import Imsd; @@ -854,6 +858,189 @@ int main() { Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI"); } + // ---- RegRecovery: when a registration stops working after bring-up. + // The clock is ours: seconds, starting at 1000. + { + using F = RegRecovery::Failure; + using S = RegRecovery::Step; + auto reconnectAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Reconnect && p.at == at; }; + auto freshAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Fresh && p.at == at; }; + RegRecovery r; + Check(!r.Fresh() && r.Reconnectable() && reconnectAt(r, 1000, 0), "the first step is a reconnect, at once"); + r.OnLocalRefusal(1000); + Check(r.refusals == 0 && r.silentCycles == 0 && r.reconnectDelay == 5 && reconnectAt(r, 1000, 1005), "a local connect refusal: retried in 5 s, not counted, backoff untouched"); + // O2 2026-09-24, 09:29: the keepalive refresh refused (503), then a + // reconnect's refresh refused again -> fresh, not a third refresh + r.OnReconnectFailure(1000, F::Refused); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first refusal: reconnect after 5 s, not yet an escalation"); + r.OnReconnectFailure(1005, F::Refused); + Check(r.Fresh() && r.refusals == 2 && freshAt(r, 1005, 1015), "second refusal: escalated, the fresh attempt after the doubled backoff"); + Check(r.Reconnectable(), "…and the old SA is still there"); + r.OnSuccess(); + Check(!r.Fresh() && r.refusals == 0 && r.reconnectDelay == 5, "success resets the policy"); + // Retry-After is the network's word: it holds every attempt for its + // own length, next to the backoff, and a Dial cuts only the backoff + r.OnReconnectFailure(1000, F::Refused, 90); + Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff"); + r.OnDial(); + Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it"); + r.OnReconnectFailure(1090, F::Refused, 3); + Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); + r.OnDial(); + Check(freshAt(r, 1090, 1093), "…but a Dial cuts the backoff down to it, no further"); + r.OnSuccess(); + // a 403 once a fresh registration is due: the old registration is gone + { + RegRecovery k; + k.OnReconnectFailure(1000, F::Refused, 0, 403); + Check(!k.Fresh() && k.Reconnectable(), "a first 403 is a refusal like the others"); + k.OnReconnectFailure(1005, F::Refused, 0, 403); + Check(k.Fresh() && !k.Reconnectable() && freshAt(k, 1005, 1015), "a 403 once fresh is due: gone, the fresh attempt after the backoff"); + k.OnSuccess(); + Check(k.Reconnectable(), "success forgets it"); + } + // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered + r.OnReconnectFailure(1000, F::Silent); + Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); + r.OnReconnectFailure(1005, F::Silent); + Check(r.Fresh() && r.silentCycles == 2, "two silent cycles escalate"); + // a mix: one refusal + one silence keeps reconnecting (neither + // threshold reached) + RegRecovery m; + m.OnReconnectFailure(1000, F::Refused); + m.OnReconnectFailure(1000, F::Silent); + Check(!m.Fresh(), "one refusal + one silence: still reconnecting"); + m.OnReconnectFailure(1000, F::Silent); + Check(m.Fresh(), "…until a threshold is reached"); + // the reconnect backoff caps at 5 min + RegRecovery b; + for (int i = 0; i < 10; i++) b.OnReconnectFailure(1000, F::Silent); + Check(b.reconnectDelay == 300 && b.Next(1000).at == 1300, "reconnect backoff caps at 300 s"); + // fresh attempts: silence backs off 30 s -> 10 min (a Dial may cut + // it); a refusal or a throttle holds on the throttle schedule (21 + // min growing to 60), the network's word, and the old SA is + // reconnected meanwhile + RegRecovery f; + f.OldGone(); + Check(f.Fresh() && !f.Reconnectable() && freshAt(f, 1000, 0), "the old registration gone: fresh now, nothing to reconnect to"); + f.OnFreshFailure(1000, F::Silent); + Check(freshAt(f, 1000, 1030), "silent fresh attempt: 30 s"); + f.OnDial(); + Check(freshAt(f, 1000, 0), "…which a Dial cuts"); + f.OnFreshFailure(1030, F::Silent); + Check(freshAt(f, 1030, 1090), "…then 60 s"); + double last = 0; + for (int i = 0; i < 8; i++) { f.OnFreshFailure(2000, F::Silent); last = f.Next(2000).at - 2000; } + Check(last == 600 && f.freshAttempts == 10, "silent fresh backoff caps at 600 s; attempts counted"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused); // escalated, backoff 5 then 10 + g.OnFreshFailure(1010, F::Refused); + Check(g.Reconnectable() && reconnectAt(g, 1010, 1030) && g.freshHoldUntil == 1010 + 21 * 60, "refused fresh attempt: the old SA is reconnected after the backoff, the next fresh attempt held 21 min"); + Check(freshAt(g, 1010 + 21 * 60, 1010 + 21 * 60), "…and runs once the hold ends"); + g.OnDial(); + Check(g.Next(1010).step == S::Reconnect && g.Next(1010).at <= 1010, "a Dial meanwhile: the reconnect at once, never the held fresh attempt"); + g.OnFreshFailure(2000, F::Throttled); + Check(g.freshHoldUntil == 2000 + 31 * 60, "throttled next: 31 min (×1.5)"); + g.OnFreshFailure(3000, F::Refused); + Check(g.freshHoldUntil == 3000 + 46 * 60, "then 46 min"); + g.OnFreshFailure(4000, F::Refused); + Check(g.freshHoldUntil == 4000 + 60 * 60, "capped at 60 min"); + g.OnFreshFailure(5000, F::Refused); + Check(g.freshHoldUntil == 5000 + 60 * 60, "stays at 60 min"); + g.OnFreshFailure(6000, F::Refused, 7200); + Check(g.freshHoldUntil == 6000 + 3600, "a Retry-After beyond an hour is cut to the hour, the schedule's own ceiling"); + RegRecovery ra; + ra.OnFreshFailure(1000, F::Refused, 2700); + Check(ra.freshHoldUntil == 3700, "a Retry-After longer than the schedule's wait holds for its own length"); + ra.OnFreshFailure(5000, F::Refused, 86400); + Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); + RegRecovery rb; + rb.OnReconnectFailure(1000, F::Refused, 86400); + Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same"); + RegRecovery h; + h.OnFreshFailure(1000, F::Refused, 60); + Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); + h.OnFreshFailure(1000, F::Refused, 5); + Check(h.freshHoldUntil == 1030, "…but never under the 30-s floor"); + Check(h.throttleMinutes == 46, "the schedule still advances underneath (21 -> 31 -> 46)"); + h.OnFreshFailure(1000, F::Silent, 600); + Check(h.freshHoldUntil == 1030 && h.Next(1000).at == 1030, "Retry-After is a refusal's word; silence keeps its own backoff"); + Check(g.Fresh(), "a failed fresh attempt stays on the fresh step"); + g.OnSuccess(); + Check(!g.Fresh() && g.throttleMinutes == 21 && g.freshDelay == 30, "success resets the fresh schedule too"); + // an attempt that got past the SA setup took the old SAs with it + { + RegRecovery s; + s.OnFreshFailure(1000, F::Silent, 0, /*saReplaced=*/true); + Check(!s.Reconnectable() && freshAt(s, 1000, 1030), "no reply past the SA setup: the old registration is gone, the next fresh attempt on the short backoff"); + RegRecovery t; + 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's holds outlive the process + { + RegRecovery a; + a.OnReconnectFailure(1000, F::Refused); a.OnReconnectFailure(1000, F::Refused); + a.OnFreshFailure(1010, F::Throttled); + auto h = a.Holds(1100); + Check(h.Any() && h.hold == 0 && h.freshHold == 1010 + 21 * 60 - 1100 && h.throttleMinutes == 31, "holds: the seconds still to run and the schedule's place"); + RegRecovery b; + b.Restore(50, h); + Check(reconnectAt(b, 50, 0) && b.throttleMinutes == 31, "restored fresh hold: the resume (a reconnect) is free at once"); + b.OldGone(); + Check(freshAt(b, 50, 50 + h.freshHold), "…and the fresh step waits what was left of it"); + b.OnDial(); + Check(freshAt(b, 50, 50 + h.freshHold), "…which a Dial does not cut"); + RegRecovery c; + c.OnReconnectFailure(1000, F::Refused, 600); + RegRecovery d; + d.Restore(10, c.Holds(1200)); + Check(reconnectAt(d, 10, 10 + 400), "restored Retry-After: no step before it ends"); + d.Restore(20, RegRecovery::Held{30, 0, 21}); + Check(reconnectAt(d, 20, 10 + 400), "a restore never shortens a hold"); + Check(!RegRecovery{}.Holds(5).Any(), "no hold, nothing to keep"); + auto k = a.Holds(1010 + 21 * 60 + 5); + Check(k.Any() && k.hold == 0 && k.freshHold == 0 && k.throttleMinutes == 31, "the hold run out, the schedule's place is still kept"); + RegRecovery e; + e.Restore(10, k); + Check(reconnectAt(e, 10, 0) && e.throttleMinutes == 31, "…and a restart takes it over: no wait, the next refused or throttled fresh attempt on the advanced schedule"); + e.OldGone(); e.OnFreshFailure(20, F::Throttled); + Check(freshAt(e, 20, 20 + 31 * 60), "…31 min, not 21"); + RegRecovery g; + g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused, 0, 403); + auto hg = g.Holds(1100); + Check(hg.Any() && hg.oldGone, "a 403 once fresh is due: the holds carry that the old registration is gone"); + RegRecovery h2; + h2.Restore(50, hg); + Check(!h2.Reconnectable() && h2.Fresh() && freshAt(h2, 50, 0), "…and the restart does not reconnect over its SAs: fresh at once"); + RegRecovery i; + i.OnFreshFailure(1000, F::Throttled, 0, true); + auto hi = i.Holds(1100); + RegRecovery j; + j.Restore(50, hi); + Check(hi.oldGone && !j.Reconnectable() && freshAt(j, 50, 50 + hi.freshHold), "an attempt past the SA setup: gone travels with the hold, the restart waits it out without resuming"); + RegRecovery m; + m.OldGone(); + Check(m.Holds(5).Any() && m.Holds(5).oldGone && m.Holds(5).freshHold == 0, "gone with no hold left is still kept"); + RegRecovery n; + n.Restore(5, m.Holds(5)); + Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming"); + Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep"); + RegRecovery x; + x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); + Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from"); + } + // bring-up: a resume that failed leaves nothing to reconnect over + { + RegRecovery u; + u.OldGone(); + u.OnFreshFailure(1000, F::Throttled); + Check(!u.Reconnectable() && freshAt(u, 1000, 1000 + 21 * 60), "a throttled bring-up: the next fresh attempt in 21 min, nothing meanwhile"); + u.OnDial(); + Check(freshAt(u, 1000, 1000 + 21 * 60), "…a Dial changes nothing"); + } + } + if (Failures == 0) std::println("Engine: all tests passed"); - return Failures; + return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that }