imsd/tests/Engine/main.cpp

1117 lines
64 KiB
C++
Raw Permalink Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Engine unit tests — the call state machine driven through recorded
// network dialogs. Outgoing: reliable-18x PRACK, 2xx ACK + media start,
// media-plane far-end hangup, local hang-up (CANCEL before answer, BYE
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
// with the SDP answer at the offer's payload types + media, ACK silence,
// 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 ->
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
// 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
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// may cut short, when the old registration counts as gone, the holds a
// restart takes over (Holds/Restore), and a binding the network terminated
// (OnBindingTerminated). Pure reducers, no I/O.
import std;
import Imsd;
using namespace imsd::engine;
using imsd::msg::Context;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer = "ipsec-3gpp; spi-c=1; spi-s=2; port-c=3; port-s=4";
return c;
}
bool Has(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return true;
return false;
}
const Action* Find(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return &x;
return nullptr;
}
// First SendClient whose text starts with `method `.
const Action* Sent(const std::vector<Action>& a, std::string_view method) {
for (const auto& x : a)
if (x.type == Action::Type::SendClient && x.text.starts_with(method)) return &x;
return nullptr;
}
// Build a response with the machine's own Call-ID (so dialog matching in a
// real shell would hold; the reducer itself doesn't re-check).
std::string R183(std::string_view callid) {
return std::format(
"SIP/2.0 183 Session Progress\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Record-Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"Require: 100rel\r\n"
"RSeq: 1\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string R200(std::string_view callid) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format(
"SIP/2.0 {} {}\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n",
code, text, callid);
}
// First SendResponse whose text starts with `prefix`.
const Action* Responded(const std::vector<Action>& a, std::string_view prefix) {
for (const auto& x : a)
if (x.type == Action::Type::SendResponse && x.text.starts_with(prefix)) return &x;
return nullptr;
}
// A 380 Alternative Service final; `body` empty = no emergency
// indication (a bare redirect).
std::string Alt380(std::string_view callid, std::string_view body) {
return std::format(
"SIP/2.0 380 Alternative Service\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\n"
"Content-Type: application/3gpp-ims+xml\r\n"
"Content-Length: {}\r\n\r\n{}",
callid, body.size(), body);
}
constexpr std::string_view EmergencyXml =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
"<ims-3gpp version=\"1\"><alternative-service><type><emergency/></type>"
"<reason>emergency call detected</reason></alternative-service></ims-3gpp>";
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
// at non-default payload types (104/105) with telephone-event.
std::string MtInvite(std::string_view extraHeaders = "") {
std::string sdp =
"v=0\r\no=- 55 55 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\na=sendrecv\r\n";
return std::format(
"INVITE sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"Via: SIP/2.0/UDP [2001:db8::9]:5060;branch=z9hG4bKscscf1\r\n"
"Record-Route: <sip:[2001:db8::105]:5100;lr>\r\n"
"Record-Route: <sip:[2001:db8::9]:5060;lr>\r\n"
"Max-Forwards: 68\r\n"
"From: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: mt-call-abc123\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Session-Expires: 1800\r\n"
"{}"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}",
extraHeaders, sdp.size(), sdp);
}
}
int main() {
Context c = MakeCtx();
// ---- full answered call: INVITE -> 100 -> 183(rel)+PRACK -> 200 -> ACK
// + media -> far-end hangup
{
imsd::util::Rng rng(42);
CallMachine m(c, rng, "ims-call-1", "1233", 50004, false);
auto start = m.Start();
Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE");
2026-09-08 20:37:47 +02:00
Check(Sent(start, "INVITE ")->text.contains("\r\nFrom: <tel:+31611111111>;tag="), "MO INVITE From is our public (tel:) identity, not the IMSI IMPU");
Check(!Sent(start, "INVITE ")->text.contains("From: <sip:001010123456789@"), "MO INVITE From never the temporary IMPU once an identity is known");
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
Check(m.State() == CallState::Dialing, "starts dialing");
// 100 Trying: nothing changes
auto a100 = m.OnInviteResponse(Final(m.CallId(), 100, "Trying"));
Check(a100.empty(), "100 Trying is a no-op");
Check(m.State() == CallState::Dialing, "still dialing after 100");
// 183 with reliable 100rel + SDP: -> ringing, PRACK
auto a183 = m.OnInviteResponse(R183(m.CallId()));
Check(m.State() == CallState::Ringing, "183 -> ringing");
const Action* stAct = Find(a183, Action::Type::State);
Check(stAct && stAct->state == "ringing" && stAct->reason == "outgoing", "183 emits ringing/outgoing");
const Action* prack = Sent(a183, "PRACK ");
Check(prack != nullptr, "reliable 183 triggers PRACK");
Check(prack && prack->text.contains("RAck: 1 1 INVITE\r\n"), "PRACK RAck");
Check(prack && prack->text.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq 2");
// PRACK targets the remote Contact and routes via the Record-Route
Check(prack && prack->text.starts_with("PRACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "PRACK targets remote Contact");
Check(prack && prack->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "PRACK routes via reversed Record-Route");
// a second identical 183 must NOT re-PRACK the same RSeq
auto a183b = m.OnInviteResponse(R183(m.CallId()));
Check(Sent(a183b, "PRACK ") == nullptr, "duplicate RSeq not re-PRACKed");
// 200 OK: ACK + StartMedia + active
auto a200 = m.OnInviteResponse(R200(m.CallId()));
const Action* ack = Sent(a200, "ACK ");
Check(ack != nullptr, "200 -> ACK");
Check(ack && ack->text.starts_with("ACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "ACK targets remote Contact");
Check(ack && ack->text.contains("To: <sip:1233@ims>;tag=remote7\r\n"), "ACK carries dialog To tag");
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media != nullptr, "200 starts media");
Check(media && media->media.Valid(), "media leg valid");
Check(media && media->media.remoteIp == "2001:db8::9", "media remote ip");
Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec");
const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active");
sip: UDP for the protected leg, TCP first with a fallback imsd's protected leg (the second REGISTER and everything after it) was TCP only. A P-CSCF that never answers the TCP connect on its protected server port (O2 UK: the SYNs leave ESP-protected, nothing comes back) left the unit looping with no way forward, although the SAs, the listener sockets and the firewall rule already covered UDP. The client flow now carries a transport. UDP binds the same protected client port and connect()s the datagram socket to the P-CSCF's protected server port, so the kernel delivers that peer's datagrams to it ahead of the unconnected listener on the same port. One datagram is one message (RFC 3261 18.3: a Content-Length that fits truncates, one that does not fit discards the datagram, none means the rest of the datagram); a receive error is logged and marks the flow dead; a message over the single-packet ESP budget at the ims PDN's MTU is logged once per flow, since the kernel fragments it and a P-CSCF may drop the fragments. Every protected request's Via follows the transport; the challenge stays UDP. SIP_TRANSPORT selects the policy: auto (default) registers over TCP and, after two unanswered connects, registers again over UDP (new challenge, new SA pair) - but only on a phone whose state file does not record a successful TCP registration, so an outage on a TCP carrier never becomes a second initial REGISTER; a phone whose last registration was UDP goes straight to UDP. tcp and udp force one. A connect refused locally (EADDRNOTAVAIL: the 4-tuple still in TIME_WAIT from the previous flow) keeps retrying and is not counted as silence; SO_ERROR results are logged by name. The registration's transport is persisted so a warm resume reconnects the same way, and the resume gives up after two silences. Two things the change exposed and fixes: an engine-fatal event exited 0 ("exiting for systemd restart" with Restart=on-failure never firing), and a fresh registration refused with the P-CSCF's fresh-SA throttle (Security-Server spi-s=0) must not be retried by a 120-s restart loop, since every attempt re-arms the ~20-min window - it is now waited out in-process, growing on repeats, with D-Bus commands still served. A resume the network refuses falls through to a fresh registration. A retransmitted 200 OK to our INVITE is ACKed again and no longer starts a second media leg. Verified on KPN: resume over TCP after a binary swap, MT and MO calls with media both ways, the throttle deferral and its self-recovery, the UDP client path up to KPN dropping the datagram. The UDP success path is a field test; SIP retransmission timers over UDP are not in this change.
2026-09-17 14:58:46 +02:00
// a retransmitted 200 (RFC 3261 13.3.1.4: the UAS repeats it until our
// ACK arrives) is ACKed again and nothing else — no second media leg
auto a200b = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(a200b, "ACK ") != nullptr, "retransmitted 200 -> ACK again");
Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action");
Check(m.State() == CallState::Active, "state still active after the retransmit");
// media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
const Action* term = Find(amx, Action::Type::State);
Check(term && term->state == "terminated" && term->reason == "remote-hangup", "media exit -> terminated/remote-hangup");
Check(Has(amx, Action::Type::StopMedia), "stops media");
Check(Has(amx, Action::Type::Deleted), "emits deleted");
Check(m.Terminated(), "terminated");
}
// ---- remote BYE mid-call
{
imsd::util::Rng rng(7);
CallMachine m(c, rng, "ims-call-2", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
Check(m.State() == CallState::Active, "answered");
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/TCP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:them>;tag=remote7\r\nTo: <sip:me>;tag=it\r\n"
"Reason: SIP;cause=503;text=\"PT: Session timer expired.\"\r\n"
"Call-ID: {}\r\nCSeq: 9 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
const Action* resp = Find(ab, Action::Type::SendResponse);
Check(resp && resp->text.starts_with("SIP/2.0 200 OK"), "remote BYE answered 200");
Check(resp && resp->text.contains("CSeq: 9 BYE\r\n"), "200 echoes BYE CSeq");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
const Action* lg = Find(ab, Action::Type::Log);
Check(lg && lg->text.contains("ended: remote-hangup") && lg->text.contains("Reason: SIP;cause=503"), "teardown log names the peer's Reason");
Check(m.Terminated(), "terminated after remote BYE");
}
// ---- local hang-up before answer sends CANCEL; the 200/487 race
{
imsd::util::Rng rng(9);
CallMachine m(c, rng, "ims-call-3", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R183(m.CallId())); // ringing
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "hangup while ringing sends CANCEL");
Check(Has(ah, Action::Type::SetDeadline), "arms 487 timeout after CANCEL");
Check(!m.Terminated(), "not terminated until 487");
// a late 200 wins the race: ACK, then BYE, then terminate local-hangup
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") != nullptr, "race 200 then BYE (we cancelled)");
const Action* t = Find(ar, Action::Type::State);
Check(t && t->reason == "local-hangup", "cancel race -> local-hangup");
// no media for a cancelled call
Check(!Has(ar, Action::Type::StartMedia), "cancelled call starts no media");
}
// ---- 487 after CANCEL
{
imsd::util::Rng rng(11);
CallMachine m(c, rng, "ims-call-4", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL while dialing
auto a487 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a487, "ACK ") != nullptr, "487 is ACKed (non-2xx)");
const Action* t = Find(a487, Action::Type::State);
Check(t && t->reason == "local-hangup", "487 -> local-hangup");
Check(m.Terminated(), "terminated on 487");
}
// ---- busy / declined finals
{
for (auto [code, want] : std::vector<std::pair<int, std::string>>{
{486, "refused-or-busy"}, {480, "refused-or-busy"},
{603, "refused-or-busy"}, {500, "error"}, {403, "error"}}) {
imsd::util::Rng rng(code);
CallMachine m(c, rng, "u", "1233", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Final(m.CallId(), code, "x"));
Check(Sent(a, "ACK ") != nullptr, std::format("{} ACKed", code));
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == want, std::format("{} -> {}", code, want));
Check(m.Terminated(), std::format("{} terminates", code));
}
}
// ---- BYE after answer (local hangup during active call)
{
imsd::util::Rng rng(13);
CallMachine m(c, rng, "ims-call-5", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
auto a = m.OnHangup();
const Action* bye = Sent(a, "BYE ");
Check(bye != nullptr, "active hangup sends BYE");
Check(bye && bye->text.contains("CSeq: 3 BYE\r\n"), "BYE CSeq after ACK=1");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- setup timeout -> CANCEL, then error
{
imsd::util::Rng rng(15);
CallMachine m(c, rng, "ims-call-6", "1233", 50004, false);
m.Start();
auto a1 = m.OnDeadline(); // ring timeout
Check(Sent(a1, "CANCEL ") != nullptr, "ring timeout sends CANCEL");
Check(!m.Terminated(), "not terminated after first timeout");
auto a2 = m.OnDeadline(); // 487 never came
const Action* t = Find(a2, Action::Type::State);
Check(t && t->reason == "error", "second timeout -> error");
Check(m.Terminated(), "terminated on second timeout");
}
// ================= incoming (terminating / UAS) =======================
// ---- full accepted incoming call: INVITE -> 100+180, Accept -> 200 with
// the answer at the offer's payload types + media, ACK, remote BYE
{
imsd::util::Rng rng(21);
CallMachine m(c, rng, "ims-call-7", IncomingInvite{MtInvite()}, 50004);
Check(m.Dir() == Direction::Incoming, "direction incoming");
Check(m.State() == CallState::Incoming, "starts incoming");
Check(m.Number() == "+31612345678", "caller id from P-Asserted-Identity");
Check(m.CallId() == "mt-call-abc123", "call id from INVITE");
auto ai = m.OnInvite();
const Action* trying = Responded(ai, "SIP/2.0 100 Trying");
Check(trying != nullptr, "INVITE -> 100 Trying");
Check(trying && trying->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"), "100 leaves To untagged");
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing != nullptr, "INVITE -> 180 Ringing");
Check(ringing && !ringing->text.contains("RSeq:"), "180 unreliable when 100rel not required");
Check(Has(ai, Action::Type::SetDeadline), "arms ring timeout");
// both Vias echoed, in order
Check(ringing && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::105]:5100") && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::9]:5060"), "180 echoes all Vias");
Check(ringing && ringing->text.find("[2001:db8::105]:5100") < ringing->text.find("[2001:db8::9]:5060"), "180 Via order preserved");
Check(ringing && ringing->text.contains("Record-Route: <sip:[2001:db8::105]:5100;lr>"), "180 returns the Record-Route set");
Check(ringing && ringing->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001." "3gppnetwork.org;user=phone>;tag="), "180 mints a To tag");
// retransmitted INVITE (lost provisional): repeat the 180
auto art = m.OnRequest(MtInvite());
Check(Responded(art, "SIP/2.0 180 Ringing") != nullptr, "INVITE retransmit re-sends the 180");
Check(m.State() == CallState::Incoming, "still incoming");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok != nullptr, "Accept -> 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 104 105\r\n"), "answer uses the offer's payload types");
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog)
if (ringing && ok) {
auto tag = [](const std::string& s) {
std::size_t at = s.find(";tag=", s.find("To:"));
return s.substr(at, s.find('\r', at) - at);
};
Check(tag(ringing->text) == tag(ok->text), "180/200 share the To tag");
}
const Action* media = Find(aa, Action::Type::StartMedia);
Check(media != nullptr, "Accept starts media");
Check(media && media->media.remoteIp == "2001:db8::30c", "media ip from offer");
Check(media && media->media.remotePort == 27864, "media port from offer");
Check(media && media->media.payloadType == 104, "media pt from offer");
const Action* st = Find(aa, Action::Type::State);
Check(st && st->state == "active" && st->reason == "accepted", "Accept emits active/accepted");
Check(m.State() == CallState::Active, "active after Accept");
// a second Accept is a no-op
Check(m.OnAccept().empty(), "double Accept is a no-op");
// the caller's ACK is consumed silently
std::string ack = std::format(
"ACK sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKack\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 1 ACK\r\nContent-Length: 0\r\n\r\n", m.CallId());
Check(m.OnRequest(ack).empty(), "ACK gets no response");
// session-refresh UPDATE without a body: bodyless 200
std::string upd = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto au = m.OnRequest(upd);
const Action* u200 = Responded(au, "SIP/2.0 200 OK");
Check(u200 != nullptr, "bodyless UPDATE answered 200");
Check(u200 && u200->text.contains("Content-Length: 0"), "bodyless UPDATE gets a bodyless 200");
Check(u200 && !u200->text.contains("Session-Expires"), "no Session-Expires in the UPDATE -> none invented in the 200");
// session-audit UPDATE with Session-Expires (KPN B2BUA, ~55 s into
// every call): the 200 MUST echo it (RFC 4028 §9) or the network
// releases with "Session timer expired"
std::string se = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu2\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Min-SE: 90\r\nSession-Expires: 1800;refresher=uac\r\n"
"Supported: timer\r\n"
"Call-ID: {}\r\nCSeq: 3 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ase = m.OnRequest(se);
const Action* se200 = Responded(ase, "SIP/2.0 200 OK");
Check(se200 != nullptr, "refresh UPDATE answered 200");
Check(se200 && se200->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires");
Check(se200 && se200->text.contains("Require: timer\r\n"), "200 carries Require: timer");
// remote BYE ends it
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 4 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
Check(Responded(ab, "SIP/2.0 200 OK") != nullptr, "remote BYE answered");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
Check(m.Terminated(), "terminated");
}
// ---- reliable 180 when the caller requires 100rel; PRACK answered
{
imsd::util::Rng rng(23);
CallMachine m(c, rng, "ims-call-8", IncomingInvite{MtInvite("Require: 100rel\r\n")}, 50004);
auto ai = m.OnInvite();
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing && ringing->text.contains("Require: 100rel\r\n") && ringing->text.contains("RSeq: 1\r\n"), "Require: 100rel makes the 180 reliable");
std::string prack = std::format(
"PRACK sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKp\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 PRACK\r\nRAck: 1 1 INVITE\r\n"
"Content-Length: 0\r\n\r\n", m.CallId());
auto ap = m.OnRequest(prack);
const Action* p200 = Responded(ap, "SIP/2.0 200 OK");
Check(p200 != nullptr, "PRACK answered 200");
Check(p200 && p200->text.contains("CSeq: 2 PRACK\r\n"), "PRACK 200 echoes CSeq");
Check(m.State() == CallState::Incoming, "PRACK leaves state incoming");
}
// ---- local reject: HangUp while ringing -> 486
{
imsd::util::Rng rng(25);
CallMachine m(c, rng, "ims-call-9", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ah = m.OnHangup();
const Action* busy = Responded(ah, "SIP/2.0 486 Busy Here");
Check(busy != nullptr, "reject sends 486");
Check(busy && busy->text.contains("CSeq: 1 INVITE\r\n"), "486 answers the INVITE");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "reject -> local-hangup");
Check(m.Terminated(), "terminated after reject");
}
// ---- remote CANCEL while ringing: 200 the CANCEL, 487 the INVITE
{
imsd::util::Rng rng(27);
CallMachine m(c, rng, "ims-call-10", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
std::string cancel = std::format(
"CANCEL sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 CANCEL\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ac = m.OnRequest(cancel);
const Action* c200 = Responded(ac, "SIP/2.0 200 OK");
Check(c200 && c200->text.contains("CSeq: 1 CANCEL\r\n"), "CANCEL answered 200 on its own transaction");
const Action* r487 = Responded(ac, "SIP/2.0 487 Request Terminated");
Check(r487 && r487->text.contains("CSeq: 1 INVITE\r\n"), "the INVITE transaction gets the 487");
const Action* t = Find(ac, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote CANCEL -> remote-hangup");
Check(m.Terminated(), "terminated after remote CANCEL");
}
// an inbound INVITE around an arbitrary offer (the gateway shapes below)
auto MtInviteSdp = [](std::string_view callid, std::string_view sdp) {
return std::format(
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
callid, sdp.size(), sdp);
};
// ---- a landline caller: the PSTN gateway offers G.711 only, by static
// payload type with no rtpmap (the shape that used to be 488'd). It
// rings, and Accept answers PCMA at the offer's pt with narrowband DTMF.
{
imsd::util::Rng rng(29);
std::string sdp =
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 8 0 101\r\na=rtpmap:101 telephone-event/8000\r\na=ptime:20\r\n";
CallMachine m(c, rng, "ims-call-11", IncomingInvite{MtInviteSdp("mt-pcma", sdp)}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") == nullptr, "G.711 offer is not refused");
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "G.711 offer rings");
Check(!m.Terminated(), "G.711 call alive after INVITE");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok != nullptr, "Accept answers 200");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 8 101\r\n"), "answer keeps the offer's PCMA + DTMF pts");
Check(ok && ok->text.contains("a=rtpmap:8 PCMA/8000/1\r\n"), "answer names PCMA");
Check(ok && ok->text.contains("a=rtpmap:101 telephone-event/8000\r\n"), "DTMF at the narrowband clock");
Check(ok && !ok->text.contains("octet-align"), "no octet-align for G.711");
const Action* med = Find(aa, Action::Type::StartMedia);
Check(med && med->media.codec == "PCMA" && med->media.payloadType == 8 && !med->media.octetAlign, "media leg starts PCMA pt 8");
Check(med && med->media.remoteIp == "2001:db8::30c" && med->media.remotePort == 27864, "media leg targets the gateway");
}
// ---- a gateway offering narrowband AMR (octet-aligned) + G.711: AMR wins
{
imsd::util::Rng rng(31);
std::string sdp =
"v=0\r\no=- 6 6 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27866 RTP/AVP 8 100 110\r\n"
"a=rtpmap:100 AMR/8000/1\r\na=fmtp:100 octet-align=1;mode-set=0,2,4,7\r\n"
"a=rtpmap:110 telephone-event/8000\r\n";
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInviteSdp("mt-amrnb", sdp)}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "AMR-NB offer rings");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 100 110\r\n"), "answer picks AMR over PCMA");
Check(ok && ok->text.contains("a=rtpmap:100 AMR/8000/1\r\n") && ok->text.contains("a=fmtp:100 octet-align=1\r\n"), "answer mirrors octet-aligned AMR");
const Action* med = Find(aa, Action::Type::StartMedia);
Check(med && med->media.codec == "AMR" && med->media.payloadType == 100 && med->media.octetAlign, "media leg starts octet-aligned AMR pt 100");
}
// ---- a mobile caller offering everything: still AMR-WB (no regression)
{
imsd::util::Rng rng(33);
std::string sdp =
"v=0\r\no=- 7 7 IN IP6 2001:db8::310\r\ns=-\r\n"
"c=IN IP6 2001:db8::310\r\nt=0 0\r\n"
"m=audio 27868 RTP/AVP 116 107 8 100 111 110 13\r\n"
"a=rtpmap:116 AMR-WB/16000/1\r\na=fmtp:116 mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:107 AMR-WB/16000/1\r\na=fmtp:107 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:100 AMR/8000/1\r\na=rtpmap:111 telephone-event/16000\r\n"
"a=rtpmap:110 telephone-event/8000\r\na=rtpmap:13 CN/8000\r\n";
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInviteSdp("mt-mobile", sdp)}, 50004);
m.OnInvite();
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 107 111\r\n"), "mobile caller answered with octet-aligned AMR-WB + wideband DTMF");
const Action* med = Find(aa, Action::Type::StartMedia);
Check(med && med->media.codec == "AMR-WB" && med->media.payloadType == 107, "media leg stays AMR-WB for a mobile caller");
}
// ---- offer we cannot serve (nothing we play): 488 before ringing, and
// the log names what was offered
{
imsd::util::Rng rng(35);
std::string sdp =
"v=0\r\no=- 8 8 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 18 101\r\na=rtpmap:18 G729/8000\r\na=rtpmap:101 telephone-event/8000\r\n";
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInviteSdp("mt-g729", sdp)}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "G729-only offer refused with 488");
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
Check(m.Terminated(), "terminated on 488");
bool named = false;
for (const auto& x : ai)
if (x.type == Action::Type::Log && x.text.contains("offered: 18 G729, 101 TELEPHONE-EVENT")) named = true;
Check(named, "488 log names the offered codecs");
}
// ---- no audio line at all: 488 too
{
imsd::util::Rng rng(37);
CallMachine m(c, rng, "ims-call-15", IncomingInvite{MtInviteSdp("mt-noaudio", "v=0\r\no=- 9 9 IN IP6 2001:db8::30c\r\ns=-\r\nc=IN IP6 2001:db8::30c\r\nt=0 0\r\n")}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "offer without m=audio refused with 488");
Check(m.Terminated(), "terminated on 488 (no audio line)");
}
// ---- ring timeout: 480, missed call
{
imsd::util::Rng rng(31);
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ad = m.OnDeadline();
Check(Responded(ad, "SIP/2.0 480 Temporarily Unavailable") != nullptr, "ring timeout sends 480");
const Action* t = Find(ad, Action::Type::State);
Check(t && t->reason == "remote-hangup", "timeout reads as missed");
Check(m.Terminated(), "terminated on ring timeout");
}
// ---- our BYE after accepting: UAS dialog fields hold
{
imsd::util::Rng rng(33);
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto ah = m.OnHangup();
const Action* bye = Sent(ah, "BYE ");
Check(bye != nullptr, "hangup after accept sends BYE");
Check(bye && bye->text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact");
Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1");
Check(bye && bye->text.contains("To: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
2026-09-08 20:37:47 +02:00
Check(bye && bye->text.contains("From: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag="), "UAS BYE From is the INVITE's To URI (our tag), not the IMSI IMPU");
Check(bye && !bye->text.contains("001010123456789@ims"), "UAS BYE carries no IMSI-derived identity");
// UAS route set: Record-Route in RECEIVED order
Check(bye && bye->text.contains("Route: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\r\n"), "BYE routes via Record-Route in received order");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- media death after accept releases the call
{
imsd::util::Rng rng(35);
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE (incoming too)");
Check(m.Terminated(), "terminated");
}
// ================= emergency (stage 1) ================================
// ---- emergency dial: the INVITE targets the sos URN over the normal
// registration; a 200 is answered like any call
{
imsd::util::Rng rng(41);
CallMachine m(c, rng, "ims-call-15", "112", 50004, false, /*emergency=*/true);
auto start = m.Start();
const Action* inv = Sent(start, "INVITE ");
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency R-URI is the sos URN");
Check(inv && inv->text.contains("To: <urn:service:sos>\r\n"), "emergency To carries the URN");
Check(inv && inv->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "emergency INVITE rides the registration route");
Check(inv && inv->text.contains("m=audio"), "emergency INVITE carries an SDP offer");
Check(Has(start, Action::Type::SetDeadline), "emergency dial arms the timeout");
auto a200 = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(a200, "ACK ") != nullptr, "sos 200 is ACKed");
Check(Has(a200, Action::Type::StartMedia), "sos answer starts media");
Check(m.State() == CallState::Active, "sos call active");
}
// ---- sos rejected -> one fallback: a fresh-dialog plain INVITE of the
// digits, whose answer is then taken normally
{
imsd::util::Rng rng(43);
CallMachine m(c, rng, "ims-call-16", "112", 50004, false, true);
m.Start();
std::string firstId = m.CallId();
auto a = m.OnInviteResponse(Final(m.CallId(), 500, "Server Internal Error"));
Check(Sent(a, "ACK ") != nullptr, "sos final is ACKed");
const Action* inv2 = Sent(a, "INVITE ");
Check(inv2 && inv2->text.starts_with("INVITE sip:112;phone-context="), "fallback INVITE dials the digits");
Check(m.CallId() != firstId, "fallback is a fresh dialog");
Check(inv2 && inv2->text.contains(std::format("Call-ID: {}\r\n", m.CallId())), "fallback INVITE carries the new Call-ID");
Check(inv2 && inv2->text.contains("CSeq: 1 INVITE\r\n"), "fallback INVITE restarts CSeq");
Check(Has(a, Action::Type::SetDeadline), "fallback re-arms the timeout");
Check(!m.Terminated(), "machine survives into the fallback");
auto a2 = m.OnInviteResponse(R200(m.CallId()));
Check(Has(a2, Action::Type::StartMedia), "fallback answer starts media");
Check(m.State() == CallState::Active, "active on the fallback leg");
}
// ---- chain exhausted: the fallback leg's final terminates, no 3rd try
{
imsd::util::Rng rng(45);
CallMachine m(c, rng, "ims-call-17", "112", 50004, false, true);
m.Start();
m.OnInviteResponse(Final(m.CallId(), 500, "x")); // sos fails
auto a = m.OnInviteResponse(Final(m.CallId(), 486, "Busy Here"));
Check(Sent(a, "INVITE ") == nullptr, "no third attempt");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "refused-or-busy", "fallback final keeps its own reason");
Check(m.Terminated(), "terminated when the chain is exhausted");
}
// ---- user hangup during the sos attempt kills the chain: 487 ends it
{
imsd::util::Rng rng(47);
CallMachine m(c, rng, "ims-call-18", "112", 50004, false, true);
m.Start();
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "user hangup CANCELs the sos attempt");
auto a = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a, "INVITE ") == nullptr, "user cancel suppresses the fallback");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "user-cancelled sos -> local-hangup");
Check(m.Terminated(), "terminated");
}
// ---- setup timeout on the sos attempt: CANCEL, then the 487 falls back
{
imsd::util::Rng rng(49);
CallMachine m(c, rng, "ims-call-19", "112", 50004, false, true);
m.Start();
auto a1 = m.OnDeadline();
Check(Sent(a1, "CANCEL ") != nullptr, "sos timeout CANCELs");
auto a2 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a2, "INVITE sip:112;phone-context=") != nullptr, "timeout-cancelled sos falls back to the digits");
Check(!m.Terminated(), "chain continues after the timeout");
}
// ---- timeout CANCEL whose 487 never comes: second deadline falls back
{
imsd::util::Rng rng(51);
CallMachine m(c, rng, "ims-call-20", "112", 50004, false, true);
m.Start();
m.OnDeadline(); // CANCEL
auto a = m.OnDeadline(); // no 487 arrived
Check(Sent(a, "INVITE sip:112;phone-context=") != nullptr, "silent sos attempt still gets its fallback");
Check(!m.Terminated(), "not terminated while the chain has a step left");
}
// ---- answered during a DEADLINE-initiated CANCEL race: taken, not BYEd
{
imsd::util::Rng rng(53);
CallMachine m(c, rng, "ims-call-21", "112", 50004, false, true);
m.Start();
m.OnDeadline(); // CANCEL (not the user)
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") == nullptr, "deadline race keeps the answered call");
Check(Has(ar, Action::Type::StartMedia), "deadline race starts media");
Check(m.State() == CallState::Active, "active after the deadline race");
}
// ---- network 380 with the emergency indication upgrades a plain call
// to sos; if that also fails the chain ends (plain already tried)
{
imsd::util::Rng rng(55);
CallMachine m(c, rng, "ims-call-22", "0612345678", 50004, false); // NOT classified
m.Start();
auto a = m.OnInviteResponse(Alt380(m.CallId(), EmergencyXml));
Check(Sent(a, "ACK ") != nullptr, "380 is ACKed");
const Action* inv = Sent(a, "INVITE ");
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency 380 upgrades to the sos URN");
Check(!m.Terminated(), "call survives into the sos attempt");
auto a2 = m.OnInviteResponse(Final(m.CallId(), 500, "x"));
Check(Sent(a2, "INVITE ") == nullptr, "no loop back to the already-tried plain leg");
Check(m.Terminated(), "terminated after the upgraded attempt fails");
}
// ---- a bare 380 (no emergency indication) is an error, never sos
{
imsd::util::Rng rng(57);
CallMachine m(c, rng, "ims-call-23", "0612345678", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Alt380(m.CallId(), ""));
Check(Sent(a, "INVITE ") == nullptr, "bare 380 places no emergency call");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "error", "bare 380 -> error");
Check(m.Terminated(), "terminated on bare 380");
}
// ---- Abandon (emergency preemption): terminate bookkeeping only
{
imsd::util::Rng rng(59);
CallMachine m(c, rng, "ims-call-24", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL sent, awaiting 487
auto a = m.Abandon("preempted by emergency dial");
Check(Sent(a, "BYE ") == nullptr && Sent(a, "CANCEL ") == nullptr, "Abandon sends nothing further");
Check(Has(a, Action::Type::Deleted), "Abandon deletes the call");
Check(m.Terminated(), "abandoned machine is terminated");
}
// ---- CODECS override, terminating side: the transcoding gateway's
// mixed offer (KPN 2026-09-08) answered with G.711 A-law because the
// list says so — the on-air narrowband bench path
{
imsd::util::Rng rng(51);
std::string sdp =
"v=0\r\no=LucentPCSF 1 1 IN IP6 2001:db8::309\r\ns=-\r\n"
"c=IN IP6 2001:db8::309\r\nt=0 0\r\n"
"m=audio 41166 RTP/AVP 8 0 100 96 101 13 106\r\n"
"a=rtpmap:8 PCMA/8000\r\na=rtpmap:0 PCMU/8000\r\n"
"a=rtpmap:100 AMR/8000\r\na=fmtp:100 mode-change-period=2\r\n"
"a=rtpmap:96 AMR-WB/16000\r\na=fmtp:96 mode-change-period=2\r\n"
"a=rtpmap:101 telephone-event/8000\r\na=fmtp:101 0-15\r\n"
"a=rtpmap:106 telephone-event/16000\r\na=fmtp:106 0-15\r\n"
"a=sendrecv\r\na=ptime:20\r\na=maxptime:60\r\n";
CallMachine m(c, rng, "ims-call-20", IncomingInvite{MtInviteSdp("mt-kpn-pcma", sdp)}, 50004, {"PCMA"});
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 180 Ringing") != nullptr, "CODECS=PCMA: mixed offer rings");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 8 101\r\n"), "CODECS=PCMA: answer is PCMA + narrowband DTMF at the offer's pts");
Check(ok && ok->text.contains("a=rtpmap:8 PCMA/8000/1\r\n") && !ok->text.contains("AMR"), "CODECS=PCMA: no AMR in the answer");
const Action* med = Find(aa, Action::Type::StartMedia);
Check(med && med->media.codec == "PCMA" && med->media.payloadType == 8 && !med->media.octetAlign, "CODECS=PCMA: media leg starts G.711 A-law");
// same offer, CODECS=AMR: narrowband AMR, bandwidth-efficient as offered
imsd::util::Rng rng2(52);
CallMachine m2(c, rng2, "ims-call-21", IncomingInvite{MtInviteSdp("mt-kpn-amr", sdp)}, 50004, {"AMR"});
m2.OnInvite();
auto aa2 = m2.OnAccept();
const Action* ok2 = Responded(aa2, "SIP/2.0 200 OK");
Check(ok2 && ok2->text.contains("m=audio 50004 RTP/AVP 100 101\r\n") && !ok2->text.contains("octet-align"), "CODECS=AMR: answer is AMR-NB BE + narrowband DTMF");
const Action* med2 = Find(aa2, Action::Type::StartMedia);
Check(med2 && med2->media.codec == "AMR" && med2->media.payloadType == 100 && !med2->media.octetAlign, "CODECS=AMR: media leg starts AMR-NB BE");
// same offer, no override: unchanged — AMR-WB
imsd::util::Rng rng3(53);
CallMachine m3(c, rng3, "ims-call-22", IncomingInvite{MtInviteSdp("mt-kpn-default", sdp)}, 50004);
m3.OnInvite();
auto aa3 = m3.OnAccept();
const Action* med3 = Find(aa3, Action::Type::StartMedia);
Check(med3 && med3->media.codec == "AMR-WB" && med3->media.payloadType == 96, "no override: the same offer still lands on AMR-WB");
}
// ---- CODECS override, originating side: the INVITE offers only what the
// list names (G.711 = a landline-shaped offer toward the network)
{
imsd::util::Rng rng(54);
CallMachine m(c, rng, "ims-call-23", "0101234567", 50004, false, false, {"PCMA", "PCMU"});
auto start = m.Start();
const Action* inv = Sent(start, "INVITE ");
Check(inv && inv->text.contains("m=audio 50004 RTP/AVP 8 0 100\r\n"), "CODECS=PCMA,PCMU: INVITE offers G.711 only");
Check(inv && !inv->text.contains("AMR"), "CODECS=PCMA,PCMU: no AMR in the offer");
Check(inv && inv->text.contains("a=rtpmap:100 telephone-event/8000\r\n") && !inv->text.contains("telephone-event/16000"), "CODECS=PCMA,PCMU: narrowband DTMF only");
}
2026-09-08 20:37:47 +02:00
// ---- MO with the registered sip: identity learned (the normal case on a
// live network): From, CANCEL and BYE all carry it, never the IMSI
{
Context ca = MakeCtx();
ca.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
imsd::util::Rng rng(61);
CallMachine m(ca, rng, "ims-call-30", "1233", 50004, false);
auto start = m.Start();
const Action* inv = Sent(start, "INVITE ");
Check(inv && inv->text.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag="), "MO INVITE From is the registered sip: identity");
auto cancel = m.OnHangup();
const Action* can = Sent(cancel, "CANCEL ");
Check(can && can->text.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag="), "CANCEL From is the same identity");
Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI");
}
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
// ---- 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(r.Fresh() && r.Reconnectable() && freshAt(r, 1000, 1090), "a refusal with a Retry-After escalates at once: the fresh attempt behind the network's wait (O2 2026-10-08), the old SA kept");
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
r.OnDial();
Check(freshAt(r, 1000, 1090), "…and a Dial does not cut it");
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
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");
}
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters The keepalive refresh and the resume's re-REGISTER carried an initial registration's first-REGISTER Authorization — an empty nonce and response — in the second REGISTER's frame, with a Security-Client naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a reregistration carry the Authorization of the last successful authentication — the last received nonce and the last calculated response — and a Security-Client with the new parameter values for the two SA pairs a re-authentication would set up; TS 33.203 7.4.0 keeps the server ports and changes the client ports. O2 UK refused the refresh of the old shape with 503 on every occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone included) and accepted the fresh registrations that followed; KPN accepted the old shape without a challenge. The fresh registration now records the 401's nonce, our cnonce and the response in the registration state (auth_nonce, auth_cnonce, auth_response in the state file); a reregistration sends them verbatim, mints new SPIs and proposes PortUcNext (45063) as the client port with the server port unchanged. A registration resumed from a state file written before this change has no answer: its reregistrations carry the empty digest, as before, until its next fresh registration, and the resume says so. A 401 to a reregistration asks for re-authentication over temporary SAs on the proposed ports (TS 24.229 5.1.1.5.1), which imsd does not implement: the refresh throws it as a refusal and RegRecovery makes the next step the fresh registration, replacing the in-place answer over the old SAs. Tested with the Messages and Engine suites and on the dev phone (KPN) with a build of this commit's tree: a resume from the previous state file, a fresh registration, a resume that sent the stored answer, three refreshes at a 120-s interval, an outgoing call to a bench line and an incoming call's ring.
2026-10-10 21:00:28 +02:00
// a 401 to a refresh asks for re-authentication over temporary SAs,
// which imsd does not do: the fresh registration at once, the old
// SA kept (the network still holds the registration)
{
RegRecovery j;
j.OnReconnectFailure(1000, F::Refused, 0, 401);
Check(j.Fresh() && j.Reconnectable() && j.refusals == 1 && freshAt(j, 1000, 1005), "a 401 on the first refusal: the fresh attempt after the backoff");
RegRecovery jr;
jr.OnReconnectFailure(1000, F::Refused, 0, 503);
Check(!jr.Fresh() && reconnectAt(jr, 1000, 1005), "a bare 503 (no Retry-After) is still the hiccup case: one more refresh first");
}
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
// 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(freshAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same, the fresh attempt behind it");
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
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");
}
engine: keep the reg-event subscription alive and act on a terminated binding The reg-event subscription is the network's own account of our binding (TS 24.229 5.1.1.3), and the daemon opened a new dialog for it at every refresh and reconnect and otherwise never looked back: a refused SUBSCRIBE was forgotten, an unanswered one had no timeout, an ended subscription got nothing until the next refresh's new dialog, and a NOTIFY was logged whatever it said. O2 UK, 2026-09-24: a fresh registration's SUBSCRIBE got 500, a NOTIFY arrived all the same and showed our contact active, the flow died eight seconds after the 200 and no connect over the SA succeeded after that; the 500 was logged and nothing was done about it. Keep the subscription alive: retry a refused or unanswered SUBSCRIBE on a backoff, renew one the S-CSCF ends with deactivated, timeout, giveup or probation (RFC 6665 4.1.3; after any other reason, or none, the next refresh subscribes anew), and subscribe anew ahead of the lifetime the S-CSCF granted. A refresh or a reconnect opens no dialog while imsd holds one for the registration or has a retry scheduled. Read the reginfo for our own contact and act on its state: a NOTIFY that terminates our binding schedules a fresh registration — at once for deactivated or expired, after the network's retry-after (up to an hour) for probation, on imsd's own refusal schedule (21 min, growing to 60) for rejected or unregistered (RFC 3680). A hold already running stands, and a REGISTER wait a call kept open that still gets its 200 after such a NOTIFY leaves that registration standing. The parser is in Imsd:Sip and pinned by tests/Sip with the O2 document shapes; the policy is RegRecovery's (OnBindingTerminated, added here with its tests in tests/Engine). Tested on the dev phone (KPN) in builds of this series; not every path has run there.
2026-10-03 19:36:23 +02:00
// the network terminated our binding
{
RegRecovery n;
n.OnBindingTerminated(1000, "expired");
Check(n.Fresh() && !n.Reconnectable() && freshAt(n, 1000, 0), "expired: fresh now, nothing to reconnect to");
RegRecovery o;
o.OnBindingTerminated(1000, "probation", 300);
Check(freshAt(o, 1000, 1300), "probation: after the network's retry-after");
o.OnDial();
Check(freshAt(o, 1000, 1300), "…which a Dial does not cut");
RegRecovery p;
p.OnBindingTerminated(1000, "probation");
Check(freshAt(p, 1000, 1001), "probation without a retry-after: at least 1 s");
RegRecovery q;
q.OnBindingTerminated(1000, "rejected");
Check(freshAt(q, 1000, 1000 + 21 * 60) && !q.Reconnectable(), "rejected: on the refusal schedule, a hold, the old SA not retried");
q.OnBindingTerminated(1001, "rejected");
q.OnBindingTerminated(2000, "unregistered");
Check(q.freshHoldUntil == 1000 + 21 * 60 && q.throttleMinutes == 31, "the same termination reported again (another dialog, a retransmission): counted once");
RegRecovery u;
u.OnBindingTerminated(1000, "deactivated");
u.OnFreshFailure(1000, F::Silent);
u.OnBindingTerminated(1005, "deactivated");
Check(freshAt(u, 1005, 1030), "…nor does a repeat cut the backoff of the attempt made since");
RegRecovery v;
v.OnFreshFailure(1000, F::Refused, 3000);
v.OnBindingTerminated(1010, "rejected");
Check(!v.Reconnectable() && freshAt(v, 1010, 4000) && v.throttleMinutes == 46, "a rejected binding never shortens the network's Retry-After");
RegRecovery w;
w.OnBindingTerminated(1000, "probation", 3000);
w.OnBindingTerminated(1010, "probation", 60);
Check(freshAt(w, 1010, 4000), "…nor a second probation report the first one's retry-after");
RegRecovery x;
x.OnBindingTerminated(1000, "probation", 86400);
Check(freshAt(x, 1000, 1000 + 3600), "probation's retry-after is cut to the hour like a Retry-After");
// terminated while a reconnect cycle waited for its re-REGISTER final: the cycle's failure is no news
RegRecovery y;
y.OnReconnectFailure(1000, F::Silent);
y.OnBindingTerminated(1010, "expired");
y.OnReconnectFailure(1011, F::Silent);
Check(y.silentCycles == 1 && freshAt(y, 1011, 0), "the flow closed under the cycle: not a silent cycle, the fresh registration now");
y.OnReconnectFailure(1012, F::Refused, 90, 503);
Check(y.refusals == 0 && freshAt(y, 1012, 1102), "…a refusal with a Retry-After on it: not counted, the network's hold kept");
}
// a terminated binding's verdict outlives the process with its hold
{
RegRecovery tg;
tg.OnBindingTerminated(1000, "rejected");
auto htg = tg.Holds(1100);
Check(htg.Any() && htg.oldGone && htg.freshHold == 1000 + 21 * 60 - 1100, "a terminated binding's holds carry that the old registration is gone");
RegRecovery th;
th.Restore(50, htg);
Check(!th.Reconnectable() && freshAt(th, 50, 50 + htg.freshHold), "…and the restart does not reconnect over its SAs: the fresh attempt waits what was left");
RegRecovery ti;
ti.OnBindingTerminated(1000, "probation", 1800);
RegRecovery tj;
tj.Restore(0, ti.Holds(1300));
Check(!tj.Reconnectable() && freshAt(tj, 0, 1500), "probation's retry-after survives a restart the same way");
}
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
// 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");
engine: register fresh when the refresh is refused or nothing answers 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: <why> — attempt N, next fresh registration in M min` becomes `bring-up deferred: <why>; 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.
2026-09-24 18:56:34 +02:00
return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that
}