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 }