Emergency calling, stage 1: urn:service:sos with a digits fallback

Classify 112/911 (plus EMERGENCY_NUMBERS) at Dial. A classified call
INVITEs urn:service:sos over the existing registration and, on any
non-2xx final not caused by the user hanging up (including the
setup-timeout CANCEL paths), retries once as a plain INVITE of the
dialled digits — the pre-0.3.0 behavior, so classification can never
place a call worse than the status quo. The reverse edge: a 380
Alternative Service whose body carries the emergency indication
upgrades an unclassified call to the sos URN; a bare 380 stays an
error, since promoting an arbitrary redirect would put a
non-emergency call through to a PSAP. An emergency dial preempts an
in-progress call, and an answer racing a deadline-initiated CANCEL is
taken instead of BYE'd (user-initiated CANCEL races still BYE).

No emergency registration, no emergency PDN, no CS fallback, no
SIM-less calling, no AML — and no carrier has confirmed the sos path
end-to-end. The README warning states exactly that.

Assisted-by: Claude:claude-fable-5
This commit is contained in:
Jorijn van der Graaf 2026-08-01 22:29:12 +02:00
commit f2a404855f
8 changed files with 365 additions and 42 deletions

View file

@ -100,6 +100,24 @@ namespace {
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.
@ -525,6 +543,150 @@ int main() {
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");
}
if (Failures == 0) std::println("Engine: all tests passed");
return Failures;
}

View file

@ -222,6 +222,44 @@ int main() {
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
}
// ---- emergency INVITE: byte-identical to the plain INVITE except the
// Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6)
{
Dialog d;
d.ruri = std::string(SosUrn);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
std::string want(Invite);
auto swap = [&](std::string_view from, std::string_view to) {
std::size_t at = want.find(from);
Check(at != std::string::npos, "sos pin template line present");
if (at != std::string::npos) want.replace(at, from.size(), to);
};
swap("INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n",
"INVITE urn:service:sos SIP/2.0\r\n");
swap("To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n",
"To: <urn:service:sos>\r\n");
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), want, "emergency INVITE (urn:service:sos)");
}
// ---- IsEmergency classification
Check(IsEmergency("112"), "112 is emergency");
Check(IsEmergency("911"), "911 is emergency");
Check(IsEmergency("1 1 2"), "space separators stripped");
Check(IsEmergency("1-1-2"), "dash separators stripped");
Check(!IsEmergency("0112"), "prefixed 112 is not emergency");
Check(!IsEmergency("1120"), "suffixed 112 is not emergency");
Check(!IsEmergency("+31612345678"), "E.164 number is not emergency");
Check(!IsEmergency(""), "empty string is not emergency");
Check(!IsEmergency("199"), "extra number not classified by default");
{
std::vector<std::string> extra = {"199", "088"};
Check(IsEmergency("199", extra), "EMERGENCY_NUMBERS extra matches");
Check(IsEmergency("088", extra), "second extra matches");
Check(!IsEmergency("19", extra), "extra requires exact match");
}
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
{
Dialog d;