// SPDX-License-Identifier: GPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // lint-disable-file fixed-width-types /* Imsd:Engine — the daemon's decisions as pure reducers: the call state machine (CallMachine) and the registration recovery policy (RegRecovery, its own section below). A CallMachine owns one call's SIP dialog, outgoing or incoming. It never touches a socket, a clock, or a subprocess: each event (the INVITE's responses, an in-dialog request from the network, an accept/hang-up command, the media leg exiting, a setup timeout) is a method that mutates the dialog 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, 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/ local-hangup/remote-hangup/refused-or-busy/error). Pure std C++. */ export module Imsd:Engine; import std; import :Util; import :Sip; import :Sdp; import :Messages; export namespace imsd::engine { enum class CallState { Dialing, Ringing, Incoming, Active, Terminated }; enum class Direction { Outgoing, Incoming }; inline std::string_view StateStr(CallState s) { switch (s) { case CallState::Dialing: return "dialing"; case CallState::Ringing: return "ringing"; case CallState::Incoming: return "incoming"; case CallState::Active: return "active"; case CallState::Terminated: return "terminated"; } return "terminated"; } inline std::string_view DirectionStr(Direction d) { return d == Direction::Incoming ? "incoming" : "outgoing"; } // ---- registration recovery policy --------------------------------- // // What the daemon does when the registration stops working after // bring-up, as a pure reducer: the shell supplies the clock and the // network, this decides the next step and how long to wait for it. Two // kinds of failure reach it. A REFUSAL: the network answered a refresh // with a final other than 200 — a 401, re-authentication, included — or // a fresh registration's protected REGISTER with a final other than 200 // (the unprotected one's 401 is its challenge). SILENCE: the // flow died and reconnecting through the SA, or the refresh over it, // got no answer. Neither is retried for long on the same registration. // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after // a 408, 500, 504 or 403 on a re-REGISTER (5.1.1.2.1: not before a // Retry-After has passed), and RFC 5626 has it register again when its // flow cannot be recovered. imsd does so after any refusal: the 503s // below are not in that list. Before this reducer the daemon looped // reconnect + re-REGISTER on the same Call-ID and SA forever — O2 UK, // 2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect // cycles over 2 h 39 min, each ended only by a manual restart whose // fresh registration the network accepted within two seconds (ims // ledger F16). // // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a // Retry-After of at least a second (none, 0, unparsable) escalates to // a fresh registration after two (one 503 may be a momentary hiccup a // retried refresh clears); one with such a Retry-After escalates at // once — it is the network's considered answer (O2 2026-10-08: the // refresh after the first wait was refused again, 414 s then 772 s) — // and so does a 401, the network asking to re-authenticate, which imsd // answers with a fresh registration (TS 24.229 5.1.1.5.1's temporary // SAs on the proposed client port are not implemented). Silence // escalates after two reconnect cycles // that end without an answer — a TCP connect series whose last attempt // was not a local refusal, a bind() the kernel refused, or a refresh // that fails short of a refusal: unanswered, a failed send (the resume // at start gives up after two unanswered // connects). // A fresh registration is the expensive step: an AKA run, a // new SA pair, and on KPN a refusal (spi-s=0) while the registration on // the address is live, which costs that registration. So a refused or // throttled fresh attempt // waits on the throttle schedule (21 min, growing ×1.5 to 60) and a // silent one backs off 30 s → 10 min. Retry-After (RFC 3261 20.33) // always wins over a shorter wait. A 403 is a refusal like the others: // never hammered (the schedule), never abandoned (a SIM that is // unbarred registers within the hour); once a fresh registration is // due it also says the old registration is gone (KPN drops it the // moment an initial REGISTER arrives, throttled or not), as does a // fresh attempt that got past the SA setup, a binding the network // terminated, and a resume that failed at bring-up (OldGone) — no // reconnect over the old SA from then on. Every wait // is either the self-imposed backoff, which a user's Dial cuts short, // or the network's own word (a Retry-After, the schedule after a // refused or throttled fresh attempt, a terminated binding's probation // or its refusal schedule), which nothing cuts short — a // restart of the process neither: the shell keeps the holds (Holds) // and hands them to the next one (Restore). The shell supplies the // clock and the network; this holds the deadlines and names the next // step (Next). Success resets everything. struct RegRecovery { enum class Failure { Refused, Silent, Throttled }; enum class Step { Reconnect, Fresh }; // What comes next: the step, and the earliest time it may run (the // shell's clock; at <= now means now). struct Plan { Step step; double at; }; static constexpr int RefusalsBeforeFresh = 2; static constexpr int SilentCyclesBeforeFresh = 2; static constexpr double ReconnectDelayMin = 5; static constexpr double ReconnectDelayMax = 300; static constexpr double FreshDelayMin = 30; static constexpr double FreshDelayMax = 600; static constexpr int ThrottleMinutesMin = 21; static constexpr int ThrottleMinutesMax = 60; // The network's word is honoured up to the schedule's own longest // wait: a Retry-After beyond an hour is cut to it, and the refusal // heard again then — one REGISTER an hour at most, never a day // unregistered on one answer. static constexpr long RetryAfterMax = ThrottleMinutesMax * 60; // A connect refused locally — our own 4-tuple still in TIME_WAIT // (up to 60 s after we closed it), no route yet — is not the // network's answer: retried soon, never counted, the backoff // untouched. Dev phone 2026-09-24: four such refusals in 26 s read // as two silent cycles cost a fresh registration into the throttle. static constexpr double LocalRetry = 5; int refusals = 0; int silentCycles = 0; int freshAttempts = 0; double reconnectDelay = ReconnectDelayMin; double freshDelay = FreshDelayMin; int throttleMinutes = ThrottleMinutesMin; bool fresh = false; // the next registration attempt is a fresh one bool oldGone = false; // the old registration is gone: nothing to reconnect over bool terminated = false; // the network terminated our binding, and the policy has counted it // the deadlines, on the shell's clock double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After, probation, rejected/unregistered) bool Fresh() const { return fresh; } bool Reconnectable() const { return !oldGone; } // The network's holds as seconds still to run, and where the // schedule stands: what a restart of the process must not forget. struct Held { double hold = 0; double freshHold = 0; int throttleMinutes = ThrottleMinutesMin; bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over // a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } }; Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } // A restarted process takes them over; nothing here shortens a // hold it already has, or lengthens one past RetryAfterMax. void Restore(double now, const Held& h) { if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast(RetryAfterMax))); if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast(RetryAfterMax))); throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off } // The next step and when. Not escalated: a reconnect after the // backoff and any hold. Escalated: the fresh attempt once the // backoff, any hold and the fresh hold have passed — and while a // fresh hold still runs, a reconnect over the old SA meanwhile, the // cheap thing, unless that registration is gone. Plan Next(double now) const { double any = std::max(backoffUntil, holdUntil); double freshAt = std::max(any, freshHoldUntil); if (!fresh) return {Step::Reconnect, any}; if (!oldGone && freshHoldUntil > any && now < freshHoldUntil) return {Step::Reconnect, any}; return {Step::Fresh, freshAt}; } void OnLocalRefusal(double now) { backoffUntil = now + LocalRetry; } // A refused keepalive refresh, or a failed reconnect + refresh // cycle; `status` is the refusal's. The backoff paces the next step; // a Retry-After of at least a second holds every attempt for its own // length (up to RetryAfterMax), whichever is longer, and makes the // next step the fresh registration, as does a 401. A 403 once a fresh // registration is due says the old registration is gone (KPN drops // it the moment an initial REGISTER arrives, throttled or not). void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast(std::min(retryAfter, RetryAfterMax))); // A cycle over a registration already known gone — the network // terminated the binding while the cycle waited for its final — // fails by definition: not a silent cycle, not a refusal, and no // backoff before the fresh registration that verdict scheduled. if (oldGone) return; if (f == Failure::Refused) refusals++; else silentCycles++; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; if (f == Failure::Refused && (retryAfter > 0 || status == 401)) fresh = true; if (status == 403 && fresh) oldGone = true; backoffUntil = now + reconnectDelay; reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); } // A failed fresh registration; `saReplaced`: it got past the SA // setup, so the old registration's SAs went with it. Refusals and // throttles hold the next fresh attempt on the schedule (a Retry-After // replaces it, never below the short backoff's floor nor above // RetryAfterMax); silence backs // it off 30 s → 10 min. A reconnect over the old SA meanwhile waits // at least the reconnect backoff. void OnFreshFailure(double now, Failure f, long retryAfter = 0, bool saReplaced = false) { freshAttempts++; fresh = true; if (saReplaced) oldGone = true; backoffUntil = std::max(backoffUntil, now + reconnectDelay); if (f == Failure::Silent) { backoffUntil = std::max(backoffUntil, now + freshDelay); freshDelay = std::min(freshDelay * 2, FreshDelayMax); return; } double wait = throttleMinutes * 60.0; throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); if (retryAfter > 0) wait = std::max(static_cast(std::min(retryAfter, RetryAfterMax)), FreshDelayMin); freshHoldUntil = now + wait; } // The network terminated our binding (a reg-event NOTIFY, RFC // 3680): the old registration is gone. deactivated and expired // mean a fresh registration now; probation after the network's // retry-after (up to RetryAfterMax); rejected and unregistered on the // refusal schedule — // the network's word is neither hammered nor taken as final. One // termination counts once, however often it is reported (every // reg-event dialog of the account carries it, a NOTIFY may be // retransmitted), and never shortens a hold already running. void OnBindingTerminated(double now, std::string_view event, long retryAfter = 0) { bool reported = terminated; terminated = true; oldGone = true; fresh = true; if (event == "probation") { freshHoldUntil = std::max(freshHoldUntil, now + static_cast(std::clamp(retryAfter, 1L, RetryAfterMax))); } else if (reported) { return; } else if (event == "rejected" || event == "unregistered") { freshHoldUntil = std::max(freshHoldUntil, now + throttleMinutes * 60.0); throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax); } else { backoffUntil = 0; } } // The stored registration is not there to reconnect over (a resume // that failed at bring-up found its SAs unusable, or there are none). void OldGone() { oldGone = true; fresh = true; } // A user's Dial cuts the backoff short, never the network's word. void OnDial() { backoffUntil = 0; } void OnSuccess() { *this = RegRecovery{}; } }; // Parsed SDP answer needed to bring up the media leg. `codec` is one of // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). struct MediaLeg { std::string remoteIp; int remotePort = 0; int payloadType = -1; bool octetAlign = false; std::string codec = "AMR-WB"; bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } }; struct Action { enum class Type { SendClient, // send `text` on the client TCP flow SendResponse, // answer the just-handled inbound request on its origin channel StartMedia, // bring up the media leg described by `media` StopMedia, // tear the media leg down State, // emit CallStateChanged(uni, `state`, `reason`) Deleted, // emit CallDeleted(uni); drop this machine SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() Log, }; Type type; std::string text; std::string state; std::string reason; MediaLeg media; double seconds = 0; }; // ring/answer budget, and how long to wait for a 487 after CANCEL — the // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. inline constexpr double InviteTimeout = 90.0; inline constexpr double Cancel487Timeout = 10.0; // How long an unanswered incoming call may ring before we 480 it — a // safety net behind the network's own CANCEL, so generous. inline constexpr double IncomingRingTimeout = 120.0; // Constructor tag: this machine is the UAS of the given inbound INVITE. struct IncomingInvite { std::string_view invite; }; class CallMachine { public: // `emergency`: the shell classified the dialled string as an // emergency number — the first attempt targets urn:service:sos. // `codecs`: the daemon's CODECS override — the codecs offered on an // outgoing call and accepted from an inbound offer, in preference // order; empty = the sdp module's defaults. CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false, std::vector codecs = {}) : ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)), rtpPort_(rtpPort), precond_(precond), codecs_(std::move(codecs)), attemptSos_(emergency) { ResetDialog(); } CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort, std::vector codecs = {}) : ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort), precond_(false), codecs_(std::move(codecs)), direction_(Direction::Incoming), state_(CallState::Incoming), reason_("incoming"), invite_(in.invite) { number_ = imsd::sip::CallerId(invite_); d_.callid = std::string(imsd::sip::Header(invite_, "Call-ID").value_or("")); d_.itag = rng_.Token(10); // the To tag we mint = our dialog tag d_.invBranch = rng_.Token(20); // unused as UAS; kept initialized if (auto from = imsd::sip::Header(invite_, "From")) d_.dialogTo = std::string(*from); if (auto to = imsd::sip::Header(invite_, "To")) d_.localUri = AngleUri(*to).value_or(""); if (auto ct = imsd::sip::Header(invite_, "Contact")) d_.remoteTarget = AngleUri(*ct); d_.ruri = d_.remoteTarget.value_or(""); // The UAS route set is the Record-Route in RECEIVED order // (RFC 3261 12.1.1); DialogRoute() reverses for the UAC case, so // store it reversed here to cancel that out. auto rr = imsd::sip::Headers(invite_, "Record-Route"); for (std::size_t i = rr.size(); i-- > 0;) d_.recordRoute.emplace_back(rr[i]); answerSdp_ = std::string(Body(invite_)); // the caller's offer nextCseq_ = 0; // our first in-dialog request gets CSeq 1 } const std::string& Uni() const { return uni_; } const std::string& Number() const { return number_; } const std::string& CallId() const { return d_.callid; } CallState State() const { return state_; } std::string_view StateStr() const { return engine::StateStr(state_); } Direction Dir() const { return direction_; } std::string_view DirectionStr() const { return engine::DirectionStr(direction_); } const std::string& Reason() const { return reason_; } bool Terminated() const { return state_ == CallState::Terminated; } // Build + queue the INVITE for the current attempt and arm the ring // timeout. std::vector Start() { if (attemptSos_) sosTried_ = true; else plainTried_ = true; imsd::sdp::Offer offer; offer.local = ctx_.local; offer.rtpPort = rtpPort_; offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; offer.codecs = codecs_; std::string sdp = imsd::sdp::BuildOffer(offer); std::vector a; a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_))); a.push_back(Deadline(InviteTimeout)); return a; } // UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made // reliable only when the caller Requires 100rel) and arm the ring // timeout. An offer we cannot serve — no audio line, or none of the // codecs imsd-media plays (AMR-WB, AMR, PCMA, PCMU; the CODECS // override narrows that set) — is refused with 488 up front, before // the UI ever rings; the log line names // what WAS offered, so a field journal answers "which codec did the // gateway want" without a raw SIP dump. std::vector OnInvite() { std::vector a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; MediaLeg leg = ParseAnswer(); if (!leg.Valid() || leg.codec.empty()) { a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag))); std::string why = !leg.Valid() ? "no usable audio line" : std::format("no playable codec, offered: {}", imsd::sdp::OfferedCodecs(answerSdp_)); Append(a, Finish("error", std::format("incoming offer unusable ({}); 488 sent", why))); return a; } a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag))); std::string require(imsd::sip::Header(invite_, "Require").value_or("")); if (require.contains("precondition")) a.push_back(LogAct("caller requires preconditions; answering without them")); std::vector extra; if (require.contains("100rel")) { extra.push_back("Require: 100rel"); extra.push_back("RSeq: 1"); } last180_ = imsd::msg::BuildInviteResponse(ctx_, invite_, 180, "Ringing", d_.itag, std::nullopt, extra); a.push_back(Respond(last180_)); a.push_back(Deadline(IncomingRingTimeout)); return a; } // UAS: the user answered. 200 OK with the SDP answer (the offer's own // payload types, RFC 3264), media up, active. The caller's ACK needs // no action when it arrives (OnRequest ignores ACK). std::vector OnAccept() { std::vector a; if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a; MediaLeg leg = ParseAnswer(); imsd::sdp::AnswerSpec spec; spec.local = ctx_.local; spec.rtpPort = rtpPort_; spec.sessionId = rng_.UInt(1000000, 9999999); spec.payloadType = leg.payloadType; spec.octetAlign = leg.octetAlign; spec.codec = leg.codec; spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); localSdp_ = imsd::sdp::BuildAnswer(spec); // session timers: echo the caller's Session-Expires; with no // refresher stated, make the caller (uac) the refresher — its // re-INVITE/UPDATE refreshes get 200d by OnRequest. std::vector extra; if (auto se = imsd::sip::Header(invite_, "Session-Expires")) { std::string v(*se); if (!v.contains("refresher=")) v += ";refresher=uac"; extra.push_back(std::format("Session-Expires: {}", v)); extra.push_back("Require: timer"); } a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 200, "OK", d_.itag, localSdp_, extra))); a.push_back(StartMediaAct(leg)); state_ = CallState::Active; a.push_back(StateAct("accepted")); return a; } // A response to our INVITE (the shell already matched Call-ID + CSeq). std::vector OnInviteResponse(std::string_view msg) { std::vector a; if (direction_ == Direction::Incoming) return a; // a UAS has no INVITE transaction of its own AbsorbDialog(msg); auto st = imsd::sip::Status(msg); if (!st) return a; int code = *st; if (code > 100 && code < 200) { if (state_ == CallState::Dialing) { state_ = CallState::Ringing; a.push_back(StateAct("outgoing")); } auto rseq = imsd::sip::Header(msg, "RSeq"); std::string require = std::string(imsd::sip::Header(msg, "Require").value_or("")); if (rseq && require.contains("100rel") && !pracked_.contains(std::string(*rseq))) { pracked_.insert(std::string(*rseq)); std::array extra = { std::format("RAck: {} 1 INVITE", *rseq)}; a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "PRACK", nextCseq_, rng_.Token(20), extra))); nextCseq_++; } return a; } if (code < 200) return a; // 1xx other than 100 OnRequest(std::string_view msg) { std::vector a; std::string_view method = Method(msg); if (method == "ACK") return a; // ACK is never responded to (RFC 3261 17.1.1.2) std::string_view body = Body(msg); if (body.contains("m=audio")) answerSdp_ = std::string(body); if (direction_ == Direction::Incoming && state_ == CallState::Incoming) { if (method == "CANCEL") { // the caller gave up: 200 the CANCEL transaction, 487 the // INVITE transaction (RFC 3261 9.2), done. a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 487, "Request Terminated", d_.itag))); Append(a, Finish("remote-hangup", WithReason("CANCEL before answer", msg))); } else if (method == "INVITE") { // retransmitted INVITE (our provisional got lost): repeat it a.push_back(Respond(last180_)); } else { // PRACK of our reliable 180, or anything else: 200 echo a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); } return a; } if (method == "UPDATE" || method == "INVITE") { std::optional ans; if (direction_ == Direction::Incoming) { // session-refresh: re-state the answer we gave if the // refresh carries an offer; a bodyless refresh gets a // bodyless 200 (RFC 3311/4028) if (body.contains("m=audio")) ans = localSdp_; } else { imsd::sdp::Offer offer; offer.local = ctx_.local; offer.rtpPort = rtpPort_; offer.sessionId = rng_.UInt(1000000, 9999999); offer.precond = precond_; offer.currRemote = "sendrecv"; offer.codecs = codecs_; ans = imsd::sdp::BuildOffer(offer); } // RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires, // or the refresher treats the session as unrefreshed. KPN's // B2BUA audits ~55 s into every call with such an UPDATE and // releases ("PT: Session timer expired") on a bare 200. std::vector extra; if (auto se = imsd::sip::Header(msg, "Session-Expires")) { std::string v(*se); if (!v.contains("refresher=")) v += ";refresher=uac"; extra.push_back(std::format("Session-Expires: {}", v)); extra.push_back("Require: timer"); } a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg, ans ? std::optional(*ans) : std::nullopt, extra))); } else if (method == "BYE") { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); Append(a, Finish("remote-hangup", WithReason("remote BYE", msg))); } else if (method == "CANCEL") { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); Append(a, Finish("remote-hangup", WithReason("remote CANCEL", msg))); } else { a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg))); } return a; } std::vector OnHangup() { std::vector a; if (direction_ == Direction::Incoming && state_ == CallState::Incoming) { // reject the unanswered call a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 486, "Busy Here", d_.itag))); Append(a, Finish("local-hangup", "rejected (486 sent)")); return a; } if (state_ == CallState::Active) { 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) { // 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 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. std::vector OnMediaExit(int code) { std::vector a; if (state_ != CallState::Active) return a; nextCseq_++; a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20)))); Append(a, Finish("remote-hangup", code == 3 ? "far-end hangup (downlink RTP stopped); BYE sent" : std::format("media leg died (code {}); BYE sent", code))); return a; } // A setup deadline fired (never armed once Active). std::vector OnDeadline() { std::vector a; if (state_ == CallState::Active) return a; if (direction_ == Direction::Incoming) { if (state_ == CallState::Incoming) { // rang unanswered past the safety net; the missed call // reads as remote-hangup, like a network CANCEL would a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 480, "Temporarily Unavailable", d_.itag))); Append(a, Finish("remote-hangup", "rang unanswered past the deadline (480 sent)")); } return a; } if (!cancelled_ && (state_ == CallState::Dialing || state_ == CallState::Ringing)) { 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")); } return a; } // The shell couldn't send (socket error) — collapse the call. std::vector Fail(std::string_view reason) { std::vector a; Append(a, Finish(reason, "client-flow send failed")); return a; } private: const imsd::msg::Context& ctx_; imsd::util::Rng& rng_; std::string uni_, number_; int rtpPort_; bool precond_; std::vector codecs_; // CODECS override; empty = defaults Direction direction_ = Direction::Outgoing; CallState state_ = CallState::Dialing; std::string reason_ = "outgoing"; std::string invite_; // incoming only: the inbound INVITE verbatim std::string last180_; // incoming only: our provisional, for retransmits std::string localSdp_; // incoming only: the SDP answer we sent 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 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 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 Retry(bool sos, std::string_view why) { attemptSos_ = sos; ResetDialog(); state_ = CallState::Dialing; std::vector 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{} : msg.substr(at + 4); } static std::string_view Method(std::string_view msg) { std::size_t sp = msg.find(' '); return sp == std::string_view::npos ? msg : msg.substr(0, sp); } static std::optional AngleUri(std::string_view v) { std::size_t lt = v.find('<'); if (lt == std::string_view::npos) return std::string(v); std::size_t gt = v.find('>', lt); if (gt == std::string_view::npos) return std::string(v); return std::string(v.substr(lt + 1, gt - lt - 1)); } // Fold a response's dialog-establishing headers into the dialog. void AbsorbDialog(std::string_view msg) { if (auto to = imsd::sip::Header(msg, "To")) if (to->contains("tag=")) d_.dialogTo = std::string(*to); auto rr = imsd::sip::Headers(msg, "Record-Route"); if (!rr.empty()) { d_.recordRoute.clear(); for (auto v : rr) d_.recordRoute.emplace_back(v); } if (auto ct = imsd::sip::Header(msg, "Contact")) d_.remoteTarget = AngleUri(*ct); std::string_view body = Body(msg); if (body.contains("m=audio")) answerSdp_ = std::string(body); } MediaLeg ParseAnswer() const { imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_, codecs_); MediaLeg leg; leg.remoteIp = ans.ip; leg.remotePort = ans.port < 0 ? 0 : ans.port; leg.payloadType = ans.payloadType; leg.octetAlign = ans.octetAlign; leg.codec = ans.codec; return leg; } // Terminate: state change, media teardown, deletion — in that order, // so the UI leaves the call page before the (slow) media teardown. // One journald line per teardown — who ended the call and why // (the s59 60-second-drop hunt ran blind for lack of exactly // this; the network's Reason header named the cause all along). // `detail` carries the SIP-level cause when the caller knows it. std::vector Finish(std::string_view reason, std::string_view detail = "") { state_ = CallState::Terminated; reason_ = std::string(reason); std::vector a; a.push_back(LogAct(detail.empty() ? std::format("call {} ended: {}", uni_, reason) : std::format("call {} ended: {} — {}", uni_, reason, detail))); a.push_back(StateAct(reason)); a.push_back(Action{Action::Type::StopMedia}); a.push_back(Action{Action::Type::Deleted}); return a; } // "remote BYE, Reason: SIP;cause=503;text=..." — the peer's own // stated cause, when it sent one. static std::string WithReason(std::string_view what, std::string_view msg) { auto r = imsd::sip::Header(msg, "Reason"); return r ? std::format("{}, Reason: {}", what, *r) : std::string(what); } Action Send(std::string text) { return Action{Action::Type::SendClient, std::move(text)}; } Action Respond(std::string text) { return Action{Action::Type::SendResponse, std::move(text)}; } Action StateAct(std::string_view reason) { reason_ = std::string(reason); Action a{Action::Type::State}; a.state = std::string(engine::StateStr(state_)); a.reason = reason_; return a; } Action StartMediaAct(const MediaLeg& leg) { Action a{Action::Type::StartMedia}; a.media = leg; return a; } Action Deadline(double s) { Action a{Action::Type::SetDeadline}; a.seconds = s; return a; } Action LogAct(std::string text) { return Action{Action::Type::Log, std::move(text)}; } static void Append(std::vector& into, std::vector more) { for (auto& m : more) into.push_back(std::move(m)); } }; }