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

@ -6,7 +6,7 @@
Userspace IMS/VoLTE daemon for mainline Linux phones.
> [!WARNING]
> imsd currently cannot make emergency calls
> Emergency calling is unverified
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
can't be used for actual call audio. imsd implements the whole IMS client in
@ -22,20 +22,6 @@ full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
`Accept`, and carry media the same way — driven end-to-end from a stock
dialer over the frozen D-Bus ABI. Current state:
| Piece | State |
|---|---|
| SIP message layer + TCP framing (`Imsd:Sip`) | done, tested |
| SDP offer/answer for AMR-WB (`Imsd:Sdp`) | done, tested |
| USIM AKA + AKAv1-MD5 digest, SIM parsers (`Imsd:Aka`) | done, tested |
| IPsec SA commands + warm-SA reader (`Imsd:Ipsec`) | done, tested |
| SIP request/response builders (`Imsd:Messages`) | done, byte-pinned |
| Outgoing + incoming call state machine (`Imsd:Engine`) | done, tested |
| Registration (fresh + warm resume + keepalive refresh, MMTEL feature tags) | done |
| RTP + AMR-WB media plane into PipeWire (`imsd-media`) | done |
| D-Bus service `net.catcrafts.IMS1` (register, Dial, Accept, HangUp, live media) | done |
| Plasma Dialer session backend (`imsd-dialerd`, org.kde.telephony.*) | done |
| DTMF (`SendDtmf`, RFC 4733) | not yet implemented |
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
@ -165,6 +151,7 @@ PCSCF=2001:db8::105
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) |
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses |

View file

@ -41,7 +41,7 @@ import Imsd;
namespace {
// ---- static config (env-overridable, same knobs as imsd.py) ---------------
constexpr const char* Version = "0.2.7";
constexpr const char* Version = "0.3.0";
constexpr const char* BusName = "net.catcrafts.IMS1";
constexpr const char* ObjPath = "/net/catcrafts/IMS1";
constexpr const char* Iface = "net.catcrafts.IMS1";
@ -571,6 +571,18 @@ public:
precond_ = EnvOr("PRECOND", "0") == "1";
ealgOffer_ = EnvOr("EALG", "aes-cbc");
mediaBin_ = ResolveMediaBin();
// EMERGENCY_NUMBERS: comma-separated additions to the builtin
// 112/911 (a lab core's advertised short code, carrier extras).
std::string extra = EnvOr("EMERGENCY_NUMBERS", "");
for (std::size_t pos = 0; pos <= extra.size();) {
std::size_t comma = extra.find(',', pos);
std::size_t end = comma == std::string::npos ? extra.size() : comma;
std::string n = extra.substr(pos, end - pos);
std::erase(n, ' ');
if (!n.empty()) emergencyExtra_.push_back(std::move(n));
if (comma == std::string::npos) break;
pos = comma + 1;
}
}
void SetLocal(std::string l) { local_ = std::move(l); }
@ -672,6 +684,7 @@ private:
// config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
std::vector<std::string> emergencyExtra_;
int pcscfPort_ = 5060;
int rtpPort_ = 50004;
bool precond_ = false;
@ -1098,20 +1111,34 @@ private:
void HandleCmd(const Cmd& c) {
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
if (c.kind == Cmd::Kind::Dial) {
bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_);
if (call_) {
Log(std::format("dial({}) refused: call in progress", c.number));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
PostAdded(bad);
PostState(c.uni, "terminated", "error");
PostDeleted(c.uni);
return;
if (!emergency) {
Log(std::format("dial({}) refused: call in progress", c.number));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
PostAdded(bad);
PostState(c.uni, "terminated", "error");
PostDeleted(c.uni);
return;
}
// an emergency dial preempts whatever exists (TS 22.101):
// release it; if it lingers awaiting a CANCEL's 487, abandon
// it — the late final is ignored by Call-ID mismatch
Log(std::format("emergency dial: preempting call {}", call_->Uni()));
Execute(call_->OnHangup());
MaybeDropCall();
if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); }
}
// a dead client flow fails the INVITE instantly — give the
// reconnect path one immediate shot first (user action beats
// the backoff timer)
if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); }
RefreshPani();
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_);
if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info);
bool ok = Execute(call_->Start());

View file

@ -13,10 +13,12 @@ state and returns a list of Actions for the daemon shell to carry out — send
this on the client flow, answer the request on the channel it came in on,
start/stop the media leg, emit this D-Bus state change, arm this deadline.
That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, and the
terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on
Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against
recorded network dialogs with no I/O (tests/Engine).
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
emergency attempt chain (urn:service:sos first for a classified number, one
fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE,
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair —
unit-testable against recorded network dialogs with no I/O (tests/Engine).
State/reason strings are the frozen D-Bus vocabulary the dialer maps on
(dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/
@ -94,13 +96,12 @@ export namespace imsd::engine {
class CallMachine {
public:
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond)
// `emergency`: the shell classified the dialled string as an
// emergency number — the first attempt targets urn:service:sos.
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false)
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)),
rtpPort_(rtpPort), precond_(precond) {
d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain);
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
d_.itag = rng_.Token(10);
d_.invBranch = rng_.Token(20);
rtpPort_(rtpPort), precond_(precond), attemptSos_(emergency) {
ResetDialog();
}
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort)
@ -135,8 +136,12 @@ export namespace imsd::engine {
const std::string& Reason() const { return reason_; }
bool Terminated() const { return state_ == CallState::Terminated; }
// Build + queue the INVITE and arm the ring timeout.
// Build + queue the INVITE for the current attempt and arm the ring
// timeout.
std::vector<Action> Start() {
if (attemptSos_) sosTried_ = true;
else
plainTried_ = true;
imsd::sdp::Offer offer;
offer.local = ctx_.local;
offer.rtpPort = rtpPort_;
@ -239,13 +244,17 @@ export namespace imsd::engine {
return a; // 1xx other than 100<code<200 (only 100): nothing
if (code == 200) {
a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or("")))));
if (cancelled_) {
// answered in the CANCEL race — we no longer want it
if (cancelled_ && userCancelled_) {
// answered in the user's CANCEL race — we no longer want it
nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "answered during our CANCEL; BYE sent"));
return a;
}
// An answer racing a DEADLINE-initiated CANCEL is still
// wanted — the user never hung up. Matters most when the sos
// attempt is answered at the wire just as the timeout fires:
// BYEing an answering PSAP is the worst possible outcome.
MediaLeg leg = ParseAnswer();
a.push_back(StartMediaAct(leg));
state_ = CallState::Active;
@ -255,6 +264,10 @@ export namespace imsd::engine {
// non-2xx final
a.push_back(Send(imsd::msg::BuildAckNon2xx(ctx_, d_, std::string(imsd::sip::Header(msg, "To").value_or("")))));
std::string fin = WithReason(std::format("INVITE final {}", code), msg);
if (auto sos = FallbackTo(code, msg)) {
Append(a, Retry(*sos, fin));
return a;
}
if (cancelled_ || code == 487) Append(a, Finish("local-hangup", fin));
else if (code == 486 || code == 480 || code == 603) Append(a, Finish("refused-or-busy", fin));
else
@ -338,14 +351,27 @@ export namespace imsd::engine {
nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "HangUp; BYE sent"));
} else if ((state_ == CallState::Dialing || state_ == CallState::Ringing) && !cancelled_) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
} else if (state_ == CallState::Dialing || state_ == CallState::Ringing) {
// even when a deadline already CANCELled: the user's intent
// also kills the emergency fallback chain
userCancelled_ = true;
if (!cancelled_) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
}
}
return a;
}
// The shell is abandoning this machine (an emergency dial preempts
// it while it waits on a CANCEL's 487): terminate bookkeeping only —
// the CANCEL already sent stands, and the late final for this
// Call-ID is ignored by dispatch.
std::vector<Action> Abandon(std::string_view detail) {
return Finish("local-hangup", detail);
}
// The media subprocess exited. code 3 = the downlink RTP dried up
// (far-end hangup); anything else = the leg died. Either way, if the
// call was up we release it network-side and tear down.
@ -375,6 +401,10 @@ export namespace imsd::engine {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
} else if (auto sos = FallbackTo(0, {})) {
// the 487 never came; an abandoned sos attempt still gets
// its digits fallback rather than dying silently
Append(a, Retry(*sos, "no final after CANCEL"));
} else {
Append(a, Finish("error", "setup deadline expired"));
}
@ -403,9 +433,67 @@ export namespace imsd::engine {
imsd::msg::Dialog d_;
int nextCseq_ = 2;
bool cancelled_ = false;
// The emergency attempt chain (outgoing only). attemptSos_ is what
// the CURRENT attempt targets; the tried flags bound the chain to
// one step in each direction, never a loop.
bool attemptSos_ = false;
bool sosTried_ = false;
bool plainTried_ = false;
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
std::set<std::string> pracked_;
std::string answerSdp_;
// Fresh dialog identity for the current attempt. A fallback INVITE
// after a final response is a new transaction AND a clean new dialog
// attempt (fresh Call-ID) — the shell's dispatch then ignores late
// responses to the abandoned attempt by Call-ID mismatch.
void ResetDialog() {
d_ = {};
d_.ruri = attemptSos_ ? std::string(imsd::msg::SosUrn)
: imsd::msg::RuriFor(number_, ctx_.id.domain);
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
d_.itag = rng_.Token(10);
d_.invBranch = rng_.Token(20);
nextCseq_ = 2;
pracked_.clear();
answerSdp_.clear();
cancelled_ = false;
userCancelled_ = false;
}
// The emergency fallback chain — where the next attempt (if any)
// points. `msg` may be empty (deadline paths).
// - a sos INVITE that fails for any reason other than the user
// hanging up is re-attempted as a plain INVITE of the dialled
// digits: the pre-emergency-support behavior, so classification
// can only improve on the status quo, never block a call the old
// code would have placed.
// - a plain INVITE answered 380 Alternative Service whose body
// names emergency (TS 24.229 5.1.6: the network classified a
// number we didn't) is re-attempted as urn:service:sos. A 380
// without that indication terminates as an error: promoting an
// arbitrary redirect would put a non-emergency call through to a
// PSAP.
std::optional<bool> FallbackTo(int code, std::string_view msg) const {
if (userCancelled_) return std::nullopt;
if (attemptSos_) return plainTried_ ? std::nullopt : std::optional(false);
if (code == 380 && !sosTried_ && Body(msg).contains("emergency")) return true;
return std::nullopt;
}
// Switch the attempt and send its INVITE. Same uni: the dialer keeps
// seeing one continuous call, back in "dialing".
std::vector<Action> Retry(bool sos, std::string_view why) {
attemptSos_ = sos;
ResetDialog();
state_ = CallState::Dialing;
std::vector<Action> a;
a.push_back(LogAct(std::format("call {}: {} — retrying as {}", uni_, why, sos ? "emergency (urn:service:sos)" : "plain INVITE of the digits")));
a.push_back(StateAct("outgoing"));
Append(a, Start());
return a;
}
static std::string_view Body(std::string_view msg) {
std::size_t at = msg.find("\r\n\r\n");
return at == std::string_view::npos ? std::string_view{}

View file

@ -133,6 +133,27 @@ export namespace imsd::msg {
return s;
}
// The service URN an emergency INVITE targets (RFC 5031); Request-URI and
// To both carry it (TS 24.229 5.1.6). No sos subtype: a dialled string
// does not say police/ambulance/fire.
inline constexpr std::string_view SosUrn = "urn:service:sos";
// Emergency-number classification. 112 and 911 are emergency numbers on
// every network (TS 22.101 §10.1.1), so they are compiled in; `extra`
// adds carrier- or lab-specific numbers (the EMERGENCY_NUMBERS list —
// e.g. a private test core's advertised short code). Exact match after
// stripping "1 1 2"-style separators; prefixed/suffixed strings ("0112",
// "1120") are ordinary numbers. SIM EF_ECC is not read yet.
inline bool IsEmergency(std::string_view target, std::span<const std::string> extra = {}) {
std::string t;
for (char ch : target)
if (ch != ' ' && ch != '-' && ch != '.') t.push_back(ch);
if (t == "112" || t == "911") return true;
for (const std::string& e : extra)
if (!e.empty() && t == e) return true;
return false;
}
// Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national
// numbers become user=phone SIP URIs at the home domain; a bare short code
// gets phone-context.

View file

@ -5,7 +5,7 @@
# (or replaced here by a bootstrap invocation). Until then this documents
# the packaging shape.
pkgname=imsd
pkgver=0.2.7
pkgver=0.3.0
pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"

View file

@ -6,7 +6,7 @@
# apk-managed on the phone while crafter-build itself isn't packaged for
# Alpine yet. The source tarball is produced by packaging/make-bin-tarball.sh.
pkgname=imsd
pkgver=0.2.7
pkgver=0.3.0
pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"

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;