imsd/AGENTS.md

1690 lines
114 KiB
Markdown
Raw Normal View History

<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
# AGENTS.md — working on imsd
This file is for coding agents and for anyone changing imsd: what the
repository contains, how it is built, tested and packaged, how the daemon
behaves at runtime, and the conventions a change has to follow. The README
is for users: what imsd is, how to install and configure it. The
configuration variables are documented there, not here (§10).
Where this file and the code disagree, the code is right and this file has a
bug — fix it in the same change.
## 1. What imsd is
imsd is a userspace IMS/VoLTE client for mainline Linux phones. The modem
firmware on such phones brings up the IMS PDN but cannot carry call audio, so
imsd does the whole IMS client itself: SIP registration with USIM AKA
authentication over kernel IPsec (ESP), call signalling for outgoing and
incoming calls, and the RTP media plane (AMR-WB, AMR, G.711) wired into
PipeWire. A stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh
and GNOME Mobile — places and receives real VoLTE calls on a commercial
network through it.
It talks to the modem through ModemManager (`mmcli`) and `qmicli` over QRTR
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
(the USIM AKA run goes through a UIM logical channel) and, for the P-CSCF list
the network put in the ims PDN's PCO, through its own QMI WDS client
(`imsd-pcscf`, a raw QRTR socket bound to the bearer's mux data port — §13),
installs its security
associations with `ip xfrm`, and plays and records audio with the PipeWire
tools (`pw-play`, `pw-record`). In practice that means a Qualcomm modem on a
mainline kernel with working WWAN/ModemManager integration, kernel ESP, and
PipeWire. The verified device/carrier combinations are in the README.
## 2. Repository layout
| Path | What it is |
|---|---|
| `project.cpp` | The canonical build description, for [Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build). Chooses the product by `--product=`. |
| `interfaces/Imsd.cppm` | The primary module interface of `imsd-core`: `export import`s the eight partitions below. |
| `interfaces/Imsd-Util.cppm` | `imsd::util` — SIP tokens, IPv6 host/port formatting, the RFC 5952 §4 IPv6 text (`CanonicalIp6`/`CanonicalAddr`; an IPv4-mapped address spelled in hex stays hex, where §5 and `inet_ntop` write it dotted) and the comma-separated address-list parser (`ParseAddressList`), hex/base64, MD5 (RFC 1321), the fixed protected ports `PortUc`/`PortUs` (45061/45062). |
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs (the codec, its payload type and mode, and the telephone-event payload type the m-line lists at that codec's clock), `BuildAnswer` for the terminating side, the `CODECS` override. |
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `SaPeer`, the P-CSCF the kernel's pair is to, read from the `dir out` policies that carry imsd's fingerprint, `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12), and `FileNamesAnotherPair`, whether a state file's `spi_uc`/`spi_us` name the kernel's pair (unknown on either side decides nothing). |
| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. |
| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). |
| `interfaces/Imsd-Qmi.cppm` | `imsd::qmi` — the QMI WDS request bytes `imsd-pcscf` sends (Bind Mux Data Port, Set IP Family, Get Packet Service Status, Get Current Settings, Get Profile Settings), the message/TLV parser, the P-CSCF list readers (0x22 flag, 0x23 IPv4, 0x24 FQDN, 0x2E IPv6 — the last one libqmi does not model). |
| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. |
| `implementations/main.cpp` | The `imsd` daemon shell: GLib main loop, GDBus service, sockets, subprocesses, the registration sequence and the run loop that pumps the core. |
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
| `implementations/media.cpp` | `imsd-media`, the per-call RTP leg, DTMF included (RFC 4733 events, queued by `dtmf <tones>` lines on its stdin). Pure std + POSIX, dlopen's the AMR codecs. |
| `implementations/pcscf.cpp` | `imsd-pcscf`, the P-CSCF reader: the AF_QIPCRTR socket, the WDS service lookup and the Bind Mux Data Port / Set IP Family / Get Current Settings sequence around `Imsd:Qmi` (plus Get Profile Settings with `--profile`, Get Packet Service Status with `-v`). std + POSIX + the Linux QRTR UAPI (`<linux/qrtr.h>`), no GLib; never Start/Stop Network. |
| `implementations/dialerd.cpp` | `imsd-dialerd`, the Plasma Mobile session-bus backend (`org.kde.telephony.*`). |
| `implementations/ofonod.cpp` | `imsd-ofonod`, the GNOME Calls system-bus backend (`org.ofono`). |
| `tests/<Suite>/main.cpp` | The eight unit-test suites: `Util`, `Aka`, `Ipsec`, `Qmi`, `Messages`, `Engine`, `Sip`, `Sdp`. |
| `packaging/` | systemd units, the bring-up and upgrade scripts, D-Bus and polkit policies, presets, the gschema override, `APKBUILD.binary` and the CI helpers (§9). |
| `.forgejo/workflows/package.yml` | CI: builds the aarch64 apks on every push to `main` and publishes them to the Forgejo Alpine registry (§9.4). |
| `bin/`, `build/` | crafter-build's outputs and intermediates. Ignored. |
Also ignored by `.gitignore`, and never to be committed: `*.raw`, `*.rtp`,
`*.stats`, `imsreg.state`, `imsd-*.tar.gz` — captures and runtime state carry
the subscriber's IMSI/MSISDN and network addresses.
## 3. Build products
Seven products come out of the tree. C++26 modules throughout, `import std;`
in every source but the re-exporting `Imsd.cppm`, clang + libc++ + lld.
| Product | Kind | Built from | Links | Notes |
|---|---|---|---|---|
| `imsd-core` | static library | the `Imsd` module and its eight partitions | — | Pure `import std` C++, no GLib, no I/O side effects: every message, digest, SA command and call-state decision is here and unit-testable on any dev box without a bus, a modem or a phone. |
| `imsd` | executable | `implementations/main.cpp` | `imsd-core`, `imsd-bus`, gio-2.0, pthread | The daemon. Default product. |
| `imsd-bus` | static library | `interfaces/ImsdBus.cppm` | gio-2.0 | The polkit gate (`net.catcrafts.IMS1.Voice`) imsd and imsd-ofonod both link. A translation daemon that runs as root on the system bus reaches imsd as root, which polkit always allows, so it gates its own callers through this or it is a bypass. |
| `imsd-media` | executable | `implementations/media.cpp` | `-ldl -lpthread` | The RTP/AMR data plane, spawned once per call. No GLib, no core. A separate process for crash isolation and for the far-end-hangup exit-code contract (§7.6). `-- --product=media`. |
| `imsd-dialerd` | executable | `implementations/dialerd.cpp` | gio-2.0 | Plasma Mobile integration: a session daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`, bridging to imsd. GDBus translation only. A separate process because those are per-user session-bus names a root system daemon cannot own. `-- --product=dialerd`. |
| `imsd-ofonod` | executable | `implementations/ofonod.cpp` | `imsd-bus`, gio-2.0 | GNOME Calls integration: a system daemon owning `org.ofono`, presenting imsd as an oFono modem. GDBus translation plus the polkit gate on its own callers. `-- --product=ofonod`. |
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
| `imsd-pcscf` | executable | `implementations/pcscf.cpp` | `imsd-core` | The P-CSCF reader `ims-pdn-up.sh` runs once the ims PDN is up (§7.3): one QMI WDS client over a raw QRTR socket, bound to the bearer's mux data port, asking for the call's runtime settings. Prints the PCO's P-CSCF list; never sends Start Network or Stop Network (its writes are its own client's mux binding and IP family, §12), and the call stayed connected and the registration stood after it (§13). Built with `-- --product=pcscf`. |
The rule behind the split: **`imsd-core` stays GLib-free and free of I/O so
that every decision in it has a unit test.** New protocol logic goes into a
partition with a test. The shell (`main.cpp`; from 1.0.0 also `pcscf.cpp`'s
QRTR control lookup and its matching of answers by type, transaction and
message id) holds protocol decisions no
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in
use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` or the first of
`PCSCF_DISCOVERED=` at a fresh registration, the move between them and back —
§7.2); the reg-event
subscription schedule (§7.4: its retry
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`:
which message is a REGISTER's final, the dispatch of requests arriving
meanwhile and the routing of our reg-event SUBSCRIBE's final read there, the
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
12 s wait restarted on a provisional); the reregistration's headers and its
answer to a challenge (`Reregister`: the stored challenge answer, or the empty
digest for a pre-0.3.11 state file, the new SPIs, `PortUcNext`; a 401 thrown
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
as a refusal carrying its status); the state-file round trip
(`PersistState`/`LoadState`: the `security_server` key, written empty for a
value with a quote, a backslash or a newline — the choice between that list
and the entry rebuilt from the SA is `Imsd:Ipsec`'s, with a test); the
inputs to `RegRecovery` — `SipFlow::Connect`'s split of failures into local
refusals and network silence, and the catch sites that sort a failed refresh
(the keepalive's, `ReconnectCycle`'s) and a failed fresh attempt
(`FreshRecover`, `DeferBringUp`) into Refused, Silent or Throttled with the
status, the Retry-After and `saReplaced_`, the bring-up resume's catch (a
refusal's Retry-After fed as a hold, the old registration written off after
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
a failed resume that a fresh registration follows; with no fresh target only
a pair nothing can resume is written off — a 403 once fresh is due or a
binding a NOTIFY terminated under the attempt counts as one by the engine's
rule, and so does a resume it gives up: a 401, a refusal with a Retry-After,
the second refusal, the count travelling in the holds (`Reconnectable()` and
`Fresh()`, decided once after both catch arms) — and taken out of the kernel,
§7.2 step 1), the `OnLink` read that writes the old registration off
when its address has left the netdev (`FreshRecover` after a failed move,
`AddressGone`), and `PacketServiceUp`'s read of
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
ModemManager before a silent cycle is counted; the no-target reads and
reactions (1.0.0): the kernel text behind `SaPeer`, the holds' `old_gone`
and the script's `PCSCF_DISCOVERY=` — whether a pair is resumable (`SaPeer`
finds one and the holds' `old_gone` does not write it off: `!peer ||
writtenOff` in `main()`) and the choice of the idle or the fatal are shell
code — at `main()`'s idle before the bus name (a pair the holds wrote off
taken out of the kernel on the way) and at the no-target bring-up's write-off
of a dead pair, its removal and the idle or the fatal that follows (§7.3);
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
and the xfrm deletes the SA setup and the dead pair's teardown run —
`OwnXfrmDeleteCommands` builds them from the kernel text, with a test; the
shell reads that text and runs them (§12);
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every transport-mode reqid-1/2 ESP state running from such a policy's source to its destination or anchored on the UE's ims-PDN address, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
the shell's reaction to a NOTIFY
terminating our binding (the flow closed at once, or kept under a call and
closed when the call ends, a reconnect cycle's or the bring-up resume's wait
ended by it — the reducer's verdict is pinned, the reaction is not); and the
holds file's codec (`PersistHolds`/`LoadHolds`: aging by boot seconds, the
pdn/boot match, defaults for absent keys — proven on the phone only, captures
45- and 70- of 2026-09-25-polkit-voice; the `pdn` compare in canonical form on
both sides (1.0.0) is unbenched: no capture stages a holds file whose `pdn` is
spelled unlike `LOCAL`). For the schedule, a reducer next to
`RegRecovery`, with a Subscription-State parser in `Imsd:Sip` pinned by
`tests/Sip`, is the follow-up that moves it; a pure format/parse pair in
`Imsd:Engine` is the holds file's.
## 4. Building
### 4.1 Native, with crafter-build
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
crafter-build is the Crafter.Build Flatpak. It carries its own clang, lld and
libc++, and its SDK (org.freedesktop.Sdk, from Flathub) has the gio-2.0
headers, so the host needs Flatpak and nothing else:
```sh
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo
flatpak install catcrafts net.catcrafts.CrafterBuild//stable
mkdir -p ~/.local/bin
printf '#!/bin/sh\nexec flatpak run net.catcrafts.CrafterBuild//stable "$@"\n' > ~/.local/bin/crafter-build
chmod +x ~/.local/bin/crafter-build
```
`crafter-build` is a script and not an alias so that the name also resolves in
a shell that has not defined the alias (a fresh `bash -c`, a CI step);
`~/.local/bin` has to be on `PATH` (any directory on it will do). The
sandbox does not see the host's `/tmp`: keep the checkout under `/home`, or
grant its directory with `flatpak run --filesystem=<dir>`.
```sh
crafter-build # bin/imsd-<target>-<march>-<mtune>-<id>/imsd
crafter-build -- --product=media # bin/imsd-media-…/imsd-media
crafter-build -- --product=dialerd # bin/imsd-dialerd-…/imsd-dialerd
crafter-build -- --product=ofonod # bin/imsd-ofonod-…/imsd-ofonod
crafter-build -- --product=pcscf # bin/imsd-pcscf-…/imsd-pcscf
crafter-build test # the eight unit-test suites
crafter-build lint # the house-style rules (§4.2, §6)
```
Output directories under `bin/` are named `<product>-<target>-<march>-<mtune>-<id>`;
the `./bin/imsd-*/imsd` globs in §8.3 rely on that.
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
A host-native binary built this way links the Flatpak's libc++ through an
rpath into `/app/lib`: run it inside the sandbox (`flatpak run --command=sh
net.catcrafts.CrafterBuild//stable`, with `--socket=session-bus` for §8.3's
daemons — the sandbox's own session bus is a filter that refuses their names;
`crafter-build test` runs the suites there
anyway). A sysroot
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
build (§4.3) gets no such rpath — it links the sysroot's libc++.
### 4.2 The lint rules, a dependency of project.cpp
`project.cpp` imports the house-style rules (§6) from their own repository:
```cpp
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "<sha>"
import Crafter.LintRules;
```
crafter-build clones that repository — and updates the clone on later loads —
and builds it for the host whenever it loads `project.cpp`: every build does
this, CI's included, not only `lint`. The commit is pinned, so a change to
the rules reaches imsd when the pin moves. `LintRules::AddRules` registers
the rules on each product's configuration.
```sh
crafter-build lint # report every finding; exit 1 on any
crafter-build lint 'naming' # one rule (the argument is a glob)
crafter-build format # apply the transform rules in place
```
Six rules read the AST (`naming`, `no-char-pointer`, `const-local`,
`constexpr-constant`, `fixed-width-types`, `single-declaration`) and need the
module PCMs: build the five programs and the tests first. With crafter-build
2.1.0 they still do not run on every file — `implementations/main.cpp` is
among the misses (`'type_traits' file not found`).
### 4.3 Cross-compiling for a phone (aarch64 Alpine / postmarketOS)
```sh
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN (edge)
crafter-build -- --target=aarch64-alpine-linux-musl \
--sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \
--march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
# `crafter-build test` with the same flags fails: its runner is qemu-aarch64,
# which the Flatpak's sandbox does not have. Test host-native (§4.1).
```
`make-sysroot.sh` needs no root, qemu or device: it downloads
`apk-tools-static` and the Alpine keys and populates a minimal rootfs (musl,
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
libc++ with its std module source, glib and their headers, compiler-rt,
libunwind, linux-headers for `imsd-pcscf`'s `<linux/qrtr.h>`, gcc for its
`crtbeginS.o`/libgcc, which clang's `*-alpine-linux-musl` driver links
against). In a cross build `project.cpp` does not ask the host's pkg-config
(it would answer for the wrong architecture); it adds `-I=/usr/include/glib-2.0`
and `-I=/usr/lib/glib-2.0/include`, which clang resolves inside the sysroot.
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
crafter-build compiles the std module against the sysroot's libc++ headers
from the sysroot's own source — Alpine's `llvm-runtimes`, the same release
as its libc++, which `make-sysroot.sh` installs (`libc++-dev` ships none);
run over an existing sysroot the script upgrades its packages (`apk add
--upgrade`).
Without that source it falls back to the toolchain's source for the same libc++
major release, then to the sources Crafter.Build carries for other
releases. This is the flow CI runs (§9.4).
## 5. Tests
`crafter-build test` runs the eight suites. Each suite
is a plain `main()` with a `Check(bool, message)` counter: failures print
`FAIL: …`, success prints `<Suite>: all tests passed`, and the exit status is
the failure count for seven suites — non-zero short of a multiple of 256 — and
1 for `Engine`. `crafter-build test` ending in `8 passed`, on every
commit of a range (§11), is the project's definition of green.
What the suites pin:
- `Util` — tokens, IPv6 detection and host/port formatting, the RFC 5952 §4
form (the RFC's §4.1–4.2.3 examples, idempotence on `inet_ntop`-shaped
text, what is refused), the address-list parser, hex/base64, MD5.
- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
text parsers, the AUTHENTICATE APDU and its response parser.
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
Security-Server mechanism selection, the SA reader, the SA peer
(`SaPeer`: the one address a reqid-1 and a reqid-2 `dir out` transport
policy both name with imsd's protected port as the selector's sport — the
fingerprint —, the pair kept beside a VPN's policies;
a lone or disagreeing policy names no
peer), `FileNamesAnotherPair` (the pair's own SPIs, other SPIs, one
differing, no keys, a 0 from a missing inbound state, zeros in the file),
and `OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every transport-mode reqid-1/2 ESP state running from such a policy's source to its destination or anchored on the UE's ims-PDN address, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
the fixture in the registered FP6's shape (synthetic addresses and keys), a VPN's tunnel SA and policies beside it
untouched, another transport-mode user's pair on other ports and addresses
untouched — its reqid-1 ESP state on the UE's address deleted, its AH state
left —, a policy-less state on the UE's address deleted, one between
other addresses left).
- `Qmi` — the request bytes `imsd-pcscf` sends (four of the five the probe's,
byte for byte; Get Profile Settings is the helper's own, answered by the
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
modem on the dev phone in the probe capture's `--profile 2 -v` run and on the dev phone's bring-up
discoveries (the 2026-10-11/12 KPN bench), which pass `--profile` when the
ims profile index is known — the `profile-pcscf-via-pco` flag line),
the parser on the modem's 163-byte Get Current Settings answer in its exact
TLV shape (addresses re-encoded to `2001:db8::/32`), the OutOfCall answer,
the IPv4/FQDN list readers on synthetic lists, the tolerance of a count
that disagrees with a length, short and overrunning buffers, the
packet-service-status and profile PCSCF-via-PCO readers (present and absent
TLVs) and the error names (OutOfCall, NoEffect, an unknown code as `?`).
- `Messages` — every outgoing SIP message byte-for-byte, cross-generated from
the Python reference stack imsd replaced.
- `Sip` — the text layer's edge cases (empty header values, line anchoring,
keepalive discards), `ResponseOf`, refused finals with reason headers,
reginfo documents. A change that flips one of those tests is a behaviour
change, not a cleanup.
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
- `Sdp` — the offer's exact bytes, the parser (the telephone-event payload
type at the chosen codec's clock among it), `BuildAnswer` at the offer's
own payload types and octet-align mode, the `CODECS` override.
- `Engine` — the call state machine driven through recorded network dialogs
(reliable 18x/PRACK, 2xx ACK + media start, far-end hangup, CANCEL/answer
races, busy/error finals, the terminating side's 100/180/200/486/487/488,
G.711-only and AMR-NB gateway offers, ring timeout, BYE both ways, the
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
emergency chain, DTMF — sent to the leg on an active call that negotiated
telephone-event, dropped with the reason before the answer, after the
call, without the negotiation, for a character that is no tone or for a
string past `DtmfMaxTones`) and
`RegRecovery` (thresholds, backoffs, the throttle
schedule, Retry-After, which waits a Dial may cut short, when the old
registration counts as gone, the holds a restart takes over, a binding the
network terminated).
Rules:
- A decision in `imsd-core` gets a test in the suite that owns the partition.
Fixtures are recorded network traffic where any exists, inline fixtures
only where no recording exists.
- Behaviour that lives in the shell (`main.cpp`: timers, the reg-event
subscription schedule, the audio-session resolution, the hand-over scripts)
is proven on a phone where a bench can reach it; the commit message says
in one sentence that it was tested, and the arms no bench has reached are
named here. The known gaps: the reg-event subscription schedule (the
arming and KPN's
`noresource` closure have run on hardware, the 60 s retry only on a
pre-fix build that renewed that closure; the retry on this code, the
renewal firing, Timer F, the doubling, a SUBSCRIBE `Retry-After`, the
renew arm and the back-off's survival across a refresh have run nowhere —
§3, §7.4); a `Retry-After` on a REGISTER has run in the field (one
report, a screenshot of 2026-10-08, O2, 0.3.10: a refresh's Retry-After
set the next step's time — `in 7 min`, then `in 13 min` — and the
refusals counted; the wait's length went unmeasured, the journal lines
on the screenshot carry no timestamps; KPN's refusals, all 403, carry
none) and a `Dial` bringing a recovery step
forward (imsd-ofonod refuses the `Dial` itself and imsd-dialerd forwards
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
it) is pinned in `tests/Engine` only; a 401 to a reregistration (the
refreshes KPN accepted were never challenged — consecutive CSeqs on every
bench — and O2 has not run this shape) and
the escalation by one refused refresh that carries a `Retry-After` — the
escalation to a fresh registration is pinned in `tests/Engine` only; a
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
resume echoing a multi-mechanism Security-Server list on the wire (the
choice of the stored list is pinned in `tests/Ipsec` with O2's recorded
list; KPN's has one mechanism, so on the bench the stored value and the
rebuilt one are the same bytes; O2's six-entry list has not resumed on
this code, and the state-file round trip is shell code no test
reaches); a Security-Server list `PersistState` writes empty (one with
a quote, a backslash or a newline — neither KPN's nor O2's list has
one; the resume of such a file ran with a hand-edited empty key,
2026-10-11); a state write of a standing registration after a fresh
attempt that aborted before its SA setup (`RegState::securityServer`,
not the attempt's `ss_` — on KPN the old SA's retry after a throttled
attempt is refused 403, the throttled-attempt chain below, so no such
write follows here); the window that leaves a state file naming another
SA pair than the kernel's (a stop or a crash between a fresh attempt's
SA setup and its 200's state write — the check is pinned in `tests/Ipsec`
and ran on a hand-edited list, 2026-10-11; the window itself is not
staged); a
refused initial or fresh
REGISTER (403, 5xx) at bring-up or in recovery (KPN has only throttled or
stayed silent — pinned in `tests/Engine` only); the write-off of the old
registration by a recovery's fresh attempt that fails past the SA setup
(such attempts are on record only as a bring-up's fatal exit, which does
not pass that arm — pinned in `tests/Engine` only); the escalation by
two refused refreshes that carry no `Retry-After` (KPN refuses a refresh
only with the 403 that says the registration is gone — pinned in
`tests/Engine` only); of the throttled-attempt chain in recovery (a
throttled fresh attempt, the old SA retried once meanwhile with the stored
binding's Contact and refused 403, the deferred attempt accepted at the
hold's end — run 2026-10-03 on the series as it then stood) the deferred
attempt made by the
same process, since a restart inside the hold made it from the holds file
instead (the same process made it on 2026-09-24, on the build before the
policy's rework); the address moves
writing `old_gone` to the holds file (the move benches predate that
field); a SUBSCRIBE final read inside a REGISTER wait (KPN answers within
the second); the shell's handling of a NOTIFY that terminates our own
binding (§3; every captured termination is a previous contact's O2
`deactivated`, and a deregistration during a call cannot be staged);
`SipFlow::Connect`'s errno arms — socket() failing, a UDP connect()
error, a bind() refused while the address is still on the link — which
no bench reaches without fault injection (§3); a coverage gap on the air
(no bench stages a radio gap without an RF shield) — the packet-service
read's rule has run through a fake `mmcli` only (2026-10-03: `searching`
and `detached` not counted, `connecting` with `unknown` counted), its
other states, ModemManager not answering and its second site (a refresh
unanswered over a reconnected flow) not at all, and `tests/Engine` pin
nothing of it; the REGISTER wait's diverting arms — a datagram skipped on
the init port, a response to another transaction ignored, a provisional
restarting the wait, a request (an INVITE among them) dispatched and
answered under the wait; an incoming call answered from a session's
dialer since the polkit gate — imsd-dialerd's `Accept` by the active
session, or GNOME Calls' `Answer` through imsd-ofonod's gate (imsd's own
`Accept` gate has run: refused for an account without a seat session and
for nobody, accepted for root, 2026-10-04); a refused
final carrying reason headers (KPN's 403s carry none — the rendering is pinned
in `tests/Sip` only); an `AUDIO_USER` pin in imsd's own environment that
names no account (the logind fallback) or an account without a PipeWire
socket (a silent call) — a pin naming an account with one ran, 2026-10-04.
`DetectLocal`'s three-tier address ranking for a run without `LOCAL`
(§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
netdev and two cut-down ones). The 1.0.0 arms no bench has reached: a
fresh registration moving from the resumed SA peer to `PCSCF=` and back
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
when the attempt fails before the SA setup (`FreshRecover`); the
fresh-target tail of a state file another attempt left behind (its no-target
tail ran on the tip's host build, bench 23f, with a file naming another SA
pair; the check itself, `FileNamesAnotherPair`, is pinned in `tests/Ipsec`;
the window itself is not staged on the phone); another transport-mode reqid 1 + 2 pair
beside ours on other ports, and two addresses each carrying imsd's
fingerprint (`tests/Ipsec` only — a VPN's tunnel policy and a transport
reqid-1 policy beside the live pair, resumed and kept, and a lone one with
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
no pair of ours, none found, ran on the dev phone, 2026-10-11/12); the SA
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
setup's and the dead pair's deletes with
another transport-mode user's pair in the kernel (`tests/Ipsec` only — with
a VPN's tunnel policy and tunnel state there, both left in place through a
fresh registration and a dead pair's removal, they ran on the dev phone,
2026-10-12); a no-target resume the network refuses (the reducer's side of
the count's travel, `Held` → `Restore`, is pinned in `tests/Engine`; the
holds file's `refusals` key — `PersistHolds`/`LoadHolds`, shell code — and
the shell's giving the pair up on the engine's verdict are unbenched);
`FreshRecover`'s restart for want of a fresh target (a resumed no-target
registration whose recovery needs a fresh one); an empty `Service-Route`
value in the 200, ignored (KPN's 200 carries none); a no-target resume
whose refresh goes unanswered (the connect case ran on the tip's host
build, bench 23f: the pair kept for the restart, `the restart tries the
resume again`); `RESUME=0` at a no-target start
(BringUp's removal of the untried pair — the same deletes ran before the
bus name on the host's kernel, a holds write-off in front of imsd's pair,
2026-10-13); `RemoveDeadPair`'s no-peer line (`N xfrm entries of imsd's own
with no pair to resume: removing them`); `RunOwnXfrmDeletes`'
line for a delete that failed (`… -> rc N`: an entry gone between the read
and the delete); the holds file's `pdn` compare in canonical form (no
capture stages a holds file whose `pdn` is spelled unlike `LOCAL`); the
discovery arms beyond the plain found one — the v4/FQDN `also gave` line,
the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
`PCSCF=` refusal, the reader's rc-0 answer without an address — on the mock
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
bench only (S13, S19–S22, S55): KPN's PCO carries two IPv6 addresses and nothing else, so on the
real PCO the dev phone reached the found arm alone; the configured skip, the
bracketed refusal and, through the `IMSD_PCSCF` seam, the reader's failure
and its exit 2 ran on the dev phone too (2026-10-11/12); the script's idle arms (no SIM, a refused ims PDN) with the
refusal count's reset by a coverage gap, a timeout and a transient cause,
the no-coverage and locked-SIM retries and the APN-string connect of a
modem without the profile, on the mock bench only (a SIM-out boot would
reach the no-SIM idle alone; the others need a SIM-in run against a refusing
network, an outage, a locked SIM, a modem without the profile) — the daemon's `idle:` exit for
the script's keys ran on the host build (2026-10-12, exit 0), its own idle
(nothing to register to), the discovery-failed exit 1, `main()`'s removal
of a pair the holds wrote off and `imsd-ofonod` starting without waiting
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
for imsd on the dev phone (2026-10-12). DTMF ran on the dev phone (KPN,
2026-10-14 to 16, the fp6 journal's `2026-10-14-dtmf-bench-1` to
`2026-10-16-dtmf-bench-17-heard`): outgoing calls to voicemail over the bus
(AMR-WB, telephone-event 98 — three tones, a string with a character
that is no tone, a wrong call uni: the two drops' lines; benches 1, 5
to 9 and 11 to 16), 13 keys from GNOME Calls' keypad on a call to a stock phone the
network answered narrowband (AMR 99, telephone-event 100), heard there
(bench 2, the first sender; 28 keys from Plasma Dialer's keypad on two
such calls heard there on the one-sender shape, bench 17); on incoming
calls from that phone (the offer's 111 at 16 kHz)
10 and 4 keys sent from the keypad (benches 3 and 4); on Plasma Mobile,
11 keys from Plasma Dialer's keypad on an incoming call and 3 on an
outgoing narrowband one, through `imsd-dialerd` (bench 10). Every event of
ours in benches 1 to 3 and 5 to 17 is in the RFC 4733 shape on the wire
with the clock shared with the audio, and so are three of bench 4's
four: the third carried a mic frame after its first packet (seq 1761
between 1760 and 1762) — the race of the sender's first shape, a thread
of its own beside the mic's, which the sender's commit closes by having
the mic thread send the event packets in its own frames' slots (one
event of bench 4's four raced, none of the 23 live-mic events of benches
2 and 3; the loopback's mic is a file, paced like a microphone, and has
not raced it). That first shape also stamped the audio a slot early or
late per event against the capture clock on the AMR-WB calls measured —
there pw-record delivers frames in bursts of two (§13) and the thread's
140 ms window took six to eight of them: the two tones of `2#`, 10 slots
apart by construction, are 11, 9 and 10 slots apart in benches 5, 12 and
13, AMR-WB voicemail calls (`analysis-v2-bench-{5,12,13}.txt` beside
bench 14) — which the one-sender shape of bench 14 cannot (10); on a
narrowband call the first shape's stamping was not measured.
Not reached on hardware:
a network answering without telephone-event (KPN echoes it; an answer that
keeps the rtpmap off its m-line is pinned in `tests/Sdp`), the drops
before the answer and after the call and the `DtmfMaxTones` bound
(these three pinned in `tests/Engine`), the letters A–D and lower case
(pinned in `tests/Engine` and the selftest), an inbound offer without
telephone-event at the codec's clock (pinned in `tests/Sdp`), the shell's
refusal of a line over PIPE_BUF and its `not sent: no control pipe` line
on a call with no media leg (nothing pins these two), a `pipe()` failure, a write to a
dead leg's pipe (`DTMF '…' not sent: …`), a full pipe's EAGAIN, and
G.711 (the loopback only).
- `imsd-pcscf` ran on the dev phone (KPN) on 2026-10-11 against the live ims
bearer: the `--mux 1 --profile 2 -v` sequence, a plain run, and `OutOfCall`
on an unbound mux (the fp6 journal's `2026-10-11-pcscf-pco-probe` capture,
`04-…`). Not reached on hardware: exit 2 (a PCO without an IPv6 list), an
error answer to Bind Mux Data Port or Set IP Family, a lookup that finds no
WDS service, the answer timeout, skipped strays and indications, the
lookup's second send to a local node other than 1, and the `v4`/`fqdn`
output lines (`tests/Qmi` pins their parsing on synthetic TLVs only). The
QRTR control lookup and the matching of answers by type, transaction and
message id are shell code (`pcscf.cpp`) no test reaches.
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
formats, both AMR codecs), the G.711 tables, and pins the RFC 4733 event
payload bytes and the keypad-to-event codes. `MIC_SRC=<file>` (raw s16
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
for driving the leg against a synthetic RTP peer without a phone; the fp6
journal's `utilities/media_loopback.py` is that peer, and its `--dtmf`
arm writes a `dtmf <tones>` line to the leg's stdin and checks the events
on the wire (one timestamp per event, the marker, the growing duration,
the end bit three times, the shared sequence and timestamp space with the
audio; with the mic file, or without a mic — `--no-mic`, the silence tick
carrying the events) — the sender's shape is proven there and on the dev phone's wire
(the fp6 journal's dtmf benches: their pcaps go through the same checks);
what a gateway does with the events is proven only by a call to one.
- `imsd --session` and `imsd-ofonod --session` own their names on the session
bus for development without root, polkit or a phone (§8.3).
## 6. Code style and lint
The rules are the Catcrafts house style,
[Crafter.LintRules](https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules),
pulled in by `project.cpp` (§4.2); that repository's README lists the
seventeen of them. `// lint-disable-file <rule>` (by convention on line 4,
after the SPDX header and a blank line) and `// lint-disable-next-line <rule>`
suppress a rule where a file needs it; nearly every source file disables
`fixed-width-types`.
The tree was written under an earlier, project-local copy of the rules and
does not pass the shared ones yet: `crafter-build lint` reports findings in
most files — `naming` first (constants and statics are PascalCase here, the
shared rule wants camelCase), then `format-concat`, `const-local`,
`wrap-join`, `single-declaration` and `no-char-pointer` (`const-local` and
`single-declaration` the copy did not report) — and the count moves (72 on
2026-10-06, before the Qmi files; 108 in 16 of 20 files on the tree that
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
added them, 2026-10-13; 109 on the 1.0.0 tree, 2026-10-13: `naming` 69,
`format-concat` 23, `const-local` 10, `wrap-join` 3, `single-declaration` 2,
`no-char-pointer` 1 and `if-single-line` 1, the last new since). CI does not
run lint.
Beyond the rules:
- Comments explain constraints the code cannot show — a network's observed
behaviour, a spec clause, the reason a shape was chosen — and name their
evidence (a dated bench, a capture) when they rest on one. They do not
narrate what the code does.
- Log lines are the operator's interface (§7.8). A new state the daemon can
be in gets a journal line that names it; existing lines are grepped for by
scripts (`imsd-handover.sh` reads a pre-0.3.10 daemon's `bring-up deferred: …
next fresh registration in N min`), so changing one is an interface change.
- GLib only in `main.cpp`, `dialerd.cpp`, `ofonod.cpp` and `ImsdBus.cppm`.
`media.cpp` is std + POSIX. The partitions are std only.
## 7. How the daemon works
### 7.1 Processes and buses
`imsd` runs as root on the system bus as `net.catcrafts.IMS1` (object
`/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`; the frozen ABI, §8).
The bus policy `packaging/net.catcrafts.IMS1.conf` lets any local account
reach it; placing, answering and ending a call is polkit's
`net.catcrafts.IMS1.Voice` (`packaging/net.catcrafts.IMS1.policy`, shaped like
ModemManager's Voice action: the active local session and root — not an ssh
login, an inactive session or a system service account). Reads (`GetStatus`,
`GetCalls`) stay open, as ModemManager's are to every local account: the call
signals reach them all anyway, the status (the address, the P-CSCF, the
phone number) travels in no signal.
Two threads: a dedicated engine thread runs the SIP loop (blocking framed
receive on the protected client flow, the protected-port listeners for
terminating requests, media reaping, the keepalive refresh); the GLib main
loop owns the bus. D-Bus method calls push commands to the engine queue;
engine events reach the bus through `g_idle_add`, where the calls table and
the status snapshot live (main thread only).
Per answered call imsd forks `imsd-media` (`IMSD_MEDIA`, default next to the
`imsd` binary, else `/usr/libexec/imsd-media`) with the addresses, ports and
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
payload type as arguments, the codec and audio knobs in its environment
(§7.6, §10.2) and its stdin on a pipe from imsd, the control pipe the DTMF
tones travel down (§7.6).
The desktop daemons are translation layers: `imsd-dialerd` runs as the
session user (autostarted from `/etc/xdg/autostart/imsd-dialerd.desktop` in
place of plasma-dialer's `modem-daemon`, whose autostart the package hides
through `/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`);
`imsd-ofonod` runs as root on the system bus (`imsd-ofonod.service`,
`Conflicts=ofono.service`). Both consume the frozen ABI and add nothing to it.
### 7.2 Bring-up
`imsd.service` runs two `ExecStartPre=` steps before the daemon:
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
1. `/usr/libexec/ims-pdn-up.sh` brings the `ims` PDN up through ModemManager,
reads the P-CSCF list the network put in the PDN's PCO (`imsd-pcscf`;
skipped when `PCSCF=` is set), gives the first a host route (§7.3) and
writes `/run/imsd.env` with `DEV=` (the connected netdev), `LOCAL=` (the
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
PDN address ModemManager assigned), `PCSCF_DISCOVERED=` (the IPv6 addresses of
that list, at most eight, comma-separated in the modem's order; IPv4 entries
and names dropped) and `PCSCF_DISCOVERY=` (how it came to be:
found, none, failed or skipped, §7.3) — or `IMSD_IDLE=<reason>` alone when
the bring-up found nothing to do (§7.3).
2. `/usr/libexec/imsd-handover.sh` (`ExecStartPre=-`, failure-tolerant) hands
a pre-0.3.10 daemon's throttle wait over (§7.5).
The unit then reads `EnvironmentFile=-/run/imsd.env` and
`EnvironmentFile=-/etc/imsd.env` (the admin's file wins) and starts
`/usr/bin/imsd`. systemd reads those files anew for every command it spawns,
so each `ExecStartPre=` already sees the values the previous one wrote.
`TimeoutStartSec=900` covers the bring-up's worst case (a 2-minute modem wait,
a 5-minute registration gate, connect retries). `Restart=on-failure`,
`RestartSec=120`; `KillMode=mixed` with `TimeoutStopSec=10`; `StateDirectory=imsd`
(`/var/lib/imsd`).
The daemon's own bring-up (`BringUp()` in `main.cpp`):
1. The P-CSCF a fresh registration goes to is `PCSCF=` when set, else the
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
first address of `PCSCF_DISCOVERED=` (the bring-up's reading of the PDN's
PCO, §7.3); the journal says which (`pcscf: configured …` / `pcscf:
discovered … (using …)`). A resume needs neither: the kernel's SAs name
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
its P-CSCF (step 3). With neither, a bring-up that did not resume ends in
the fatal `no P-CSCF for a fresh registration: …` and the restart's retry
when the resume merely failed or the discovery did (`PCSCF_DISCOVERY=failed`,
§7.3), else in the clean `idle: no P-CSCF …` exit (§7.3) — and since no
fresh one follows, a resumable pair whose attempt failed (a silent
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
connect, an unanswered refresh, a first refusal without a Retry-After —
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
the refusal count goes to the holds) is not written off and stays for the
restart to resume, unless the
engine's rule ends it: a 401, a refusal with a Retry-After or the second
refusal (`RefusalsBeforeFresh`; the count travels in the holds, so the
restart's refusal is the second) give the resume up, a 403 once a fresh
registration is due or a binding a NOTIFY terminated under the attempt
write the registration off — either way the pair counts as one nothing
can resume. A pair nothing can resume (its UE address left the link, no
state file, a file another attempt left behind, written off before this start or
given up by the engine during the attempt) is written off (`old_gone` in the holds — written as
well for a pair `RESUME=0` leaves untried) and taken
out of the kernel first (`the SA pair to … is nothing to resume: removing
it` — by exact key, what two rules select — the fingerprinted policies and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, §12), so the next bring-up
finds nothing to resume.
2. `qmicli -d qrtr://0 --uim-get-card-status` finds the ready USIM (slot, AID);
`mmcli -m any` gives the primary SIM path and the IMEI (for the
`+sip.instance` URN); `mmcli -i <sim>` gives IMSI and MCC/MNC, from which
the IMPI/IMPU/realm are derived. `mmcli --location-get` fills
`P-Access-Network-Info`.
3. **Warm resume** when an SA pair is already in the kernel and the state
file (`STATE_FILE`, default `/var/lib/imsd/imsreg.state`) describes the
registration: reconnect through the existing SA and send a refresh
re-REGISTER (`RESUME via existing SA`, then `REGISTERED (resumed +
true-refreshed)`). This avoids the network's fresh-SA throttle. `RESUME=0`
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
forces a fresh registration where there is a fresh target; with none the
untried pair is taken out and the bring-up ends as step 1 says. A resume the network refuses is not fatal while there is a fresh target:
the fresh registration follows and replaces its SAs — at once, or after
the hold the reducer keeps (a Retry-After the refusal carries, the one
refusal that reaches the reducer with a target; a binding a NOTIFY
terminated under the resume; a fresh hold kept from before this start);
with no target every refusal reaches the reducer,
§7.4; with none it is the fatal and the
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
restart, or the idle (step 1). The SA reader, the holds file's `pdn`
key, `LOCAL` and `PCSCF` compare addresses in RFC 5952 §4 form
(`imsd::util::CanonicalAddr`: lower-case hex, leading zeros dropped, the
longest zero run compressed — what `ip xfrm` prints for a plain IPv6
address; an IPv4-mapped address is read in that hex form only, never with
the dotted tail `ip` prints), and `PCSCF=` is accepted in that one spelling
only: upper case, leading zeros or an uncompressed run are refused at
bring-up with the one to use (`PCSCF='…' is not written as ip prints it;
use PCSCF=…`), a dotted-quad tail with the rule but no spelling, a
bracketed value by the bring-up script with the rule (`write the bare
address, as ip prints it (no brackets)`), a comma-separated `PCSCF=` (a
list) with its own message (`must be one address`; in the packaged unit the
bring-up script, which runs first, fails a list before the daemon sees it)
— one spelling, so the
bring-up script, which routes the value as typed, and the daemon agree on
it. Before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` was
accepted and, compared as text against `ip xfrm`, matched no SA and
registered fresh at every restart; an upgraded phone with such a setting
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
stops at bring-up with the spelling to use. A resume follows the kernel's SA peer (`SaPeer`), not
`PCSCF=`: a registration made to another P-CSCF than today's setting is
resumed there (`pcscf: resuming on …`; imsd routes that peer itself once
`ParseExistingSa` confirms the SAs) and moves at the next fresh
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
registration (`the fresh registration moves from P-CSCF X to Y`; an
attempt that fails before the SA setup goes back while the old registration
still stands — not written off by its address leaving the link, a NOTIFY
that terminated the binding or a 403 once a fresh registration was due:
`back to P-CSCF X: …`).
A state file written for another SA pair than the kernel's — its
`spi_uc`/`spi_us` against the pair's (`FileNamesAnotherPair`), a 0.3.x
file's too: an attempt that got through the SA setup and did not live to
record, to this or another P-CSCF — is not used; the
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
bring-up registers fresh (with no fresh target: the fatal or the idle, step 1). Every fresh registration goes to `PCSCF=`, else the first
entry of `PCSCF_DISCOVERED=` that `CanonicalIp6` accepts; with neither, a bring-up that cannot resume ends as
step 1 says (the fatal after a resumable pair's failed attempt or a failed
discovery, else the clean `idle: no P-CSCF …` exit — a resume the engine
gave up or wrote off included) and a recovery step that needs one
restarts the daemon through systemd.
4. **Fresh registration** otherwise (`FRESH registration`): the target's host
route (imsd's own `ip -6 route replace`; step 3 says which target), an
unprotected REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and
the `Security-Server` list, the AKA run over a UIM logical channel
(`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed
with `ip xfrm`, the protected REGISTER over the negotiated transport
(§10.1, `SIP_TRANSPORT`), `REGISTERED (fresh)`. Then the reg-event
SUBSCRIBE (§7.4).
5. A keepalive re-REGISTER refresh at half the granted lifetime, within
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
120–1800 s (`REFRESH_INTERVAL`), only while idle. The refresh of a
registration this process made fresh is a reregistration as TS 24.229
5.1.1.4.2 has it: the same Call-ID and Contact, CSeq+1, the
Authorization of the last successful authentication verbatim (the 401's
nonce, our cnonce and response, kept in the state file), a
Security-Client with new SPIs and a new client port (`PortUcNext`,
45063) for the SA pairs a re-authentication would set up — the server
port stays (TS 33.203 7.4.0) — and the Security-Verify (the 401's list).
A registration resumed from the kernel's SAs sends the same
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
Security-Client as that refresh, the Authorization the state file holds
(a file from before 0.3.11: the empty digest) and the Security-Verify
from the state file (since 0.3.12); resumed from a file written before
0.3.12, from one whose list `PersistState` wrote empty, or from one
whose list names another SA pair than the kernel holds (an attempt the
process did not live to record: its SAs installed, its 200 not
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
written — since 1.0.0 such a file is not resumed at all when its own
`spi_uc`/`spi_us` are not the pair's, step 3), it sends —
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
on the resume's re-REGISTER, the keepalive re-REGISTERs, the reg-event
SUBSCRIBE and INVITEs, until its next fresh registration — the one
entry rebuilt from the SA
(`Imsd:Ipsec`, which makes the choice): KPN's one-entry answer to the
default offer in the same bytes, not a list such as O2's six, which RFC 3329 §2.3.1 has the server answer with a
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
494 when it does not mirror its own; a refused resume registers fresh (with
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
no fresh target: the fatal and the restart, or the idle, step 1).
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
A 401 to a
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
reregistration asks for re-authentication over temporary SAs on the
proposed ports, which imsd does not implement: it is a refusal and the
recovery is a fresh registration (§7.4). Every refresh of the pre-0.3.11
shape on record from O2 UK (an initial registration's empty digest, the
in-use SPIs) was refused with 503 (§13).
The state file is JSON: `callid`, `ftag`, `cseq`, `spi_uc`, `spi_us`,
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
`expiry`, `route`, `ppi`, `aor`, `contact_user`, `transport`, since 1.0.0
`pcscf` (the P-CSCF the registration was made to; named in the line that
reports a file another attempt left behind — the check is the SPIs',
`FileNamesAnotherPair`), since 0.3.12
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
`security_server` (the registration's Security-Verify: the 401's list,
written empty when it holds a quote, a backslash or a newline; absent,
empty or naming another SA pair than the kernel's — a file from before
0.3.12, such a list, or a file an unrecorded attempt left behind — the
resume rebuilds one mechanism from the SA, logs which, and stores that),
and since
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
0.3.11 `auth_nonce`, `auth_cnonce`, `auth_response` (a registration resumed
from an older file has none: its reregistrations — the resume's and the
keepalives', across restarts — carry the empty digest, as before 0.3.11,
until its next fresh registration, and each such resume logs it).
A failed fresh bring-up exits non-zero and systemd retries after
`RestartSec` — except a bring-up the network refused or throttled, which
waits in process on the refusal schedule (§7.4) with nothing to reconnect
over, then tries again (`bring-up deferred: …`). A registered daemon whose
address leaves the netdev with no PDN address there to move to also exits
(`engine fatal: … is no longer on qmapmux0.0 — exiting for systemd
restart`) — not at once: the loss is
noticed at the next recovery step, the refresh (every 1800 s on KPN's
7200 s grant), whose
re-REGISTER gets no reply and whose reconnect cannot bind the address (dev
phone 2026-10-02, the ims bearer disconnected under a registered daemon:
14 min from the disconnect to the exit). After `RestartSec` (120 s)
`ims-pdn-up.sh` runs again and imsd registers on the PDN's address (the
same bench: a new prefix, `REGISTERED (fresh)` six seconds into the new
start).
### 7.3 The ims PDN (`packaging/ims-pdn-up.sh`)
Finds or creates the `ims` bearer through ModemManager, connects it and
configures the muxed netdev. Idempotent; a non-zero exit fails the unit.
- Waits up to 120 s for a modem, then gates the connect attempts on network
registration (`IMS_REG_TIMEOUT`, default 300 s): some carriers attach ~2 min
after boot.
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
- Idles instead of retrying when there is nothing to do: a modem in the
`failed` state with a SIM reason (`sim-missing`, `esim-without-profiles`,
`sim-error`), checked once the modem appears and again while waiting for
registration, writes `IMSD_IDLE=no-sim` to `/run/imsd.env` and exits 0; a
profile-indexed connect series refused ten times in a row — each a
persistent 3GPP cause in ModemManager's typed error
(`ServiceOptionNotSubscribed`, `MissingOrUnknownApn`,
`OperatorDeterminedBarring`, `ServiceOptionNotAuthorizedInPlmn`,
`UserAuthenticationFailed`, `ServiceOptionNotSupported`, `Ipv4OnlyAllowed`,
`Ipv6OnlyAllowed`) with packet service at the time — writes
`IMSD_IDLE=no-ims-pdn` (the line carrying the last error). The script's own
rule, stricter than the daemon's `PacketServiceUp`: a refusal counts only
while the modem reads `registered`, `connecting` or `connected` with packet
service not `detached`; any other read (`searching`, `disconnecting`,
`unknown`, an unanswered `mmcli`), a D-Bus timeout, `esm-proc-timeout`, a
transient cause (`InsufficientResources`, `ServiceOptionOutOfOrder`,
`NetworkFailureActivation`) or any other error starts the count again, as a
coverage gap does — an outage must not idle VoLTE until the next boot. Installing the package and inserting a
SIM is the opt-in: `/etc/imsd.env` holds overrides only and its existence
changes nothing here (before 1.0.0 the apk's drop-in made it the switch for
the whole unit, §9.2). imsd reads the key, logs `idle: …` and exits 0;
`Restart=on-failure` leaves a clean exit alone, so the unit stays inactive
until the next boot, a `systemctl start imsd` or a start of `imsd-ofonod`,
whose `Wants=imsd.service` starts an idle imsd again (the Phosh image: an
`imsd-ofono` upgrade, its own restart after a crash) — one attempt per
boot, or per such start. What is not a fact keeps the retry: ten failures
without coverage — the network found only at the end or lost on the way —
exit 1 like a disabled radio, as do a SIM still waiting for its PIN (the
modem `locked`), a modem whose final profile list has no ims APN (the
APN-string connect is tried; a connected APN-string bearer — a run whose
lookup failed at boot — is reused and its registration resumed) and an
APN-string series, which a walled carrier's modem refuses locally.
The dead end after the PDN is up is the daemon's own, decided in `main()`
before it takes the bus name with the same clean exit: nothing to register
to — no `PCSCF=` and nothing discovered: `PCSCF_DISCOVERY=none` (no IPv6
P-CSCF read from the PCO — the reader's exit 2: the list absent, empty,
unreadable, or IPv4/FQDN entries only), `skipped` (a netdev without an rmnet
mux id) or no `PCSCF_DISCOVERY=` at all (a manual run) —
and no registration of its own to resume (`SaPeer` finds no pair, or the
holds for this PDN and boot say `old_gone`):
`idle: no P-CSCF — none configured (PCSCF=), none discovered (see the
ims-pdn-up lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to
name one`; a pair the holds wrote off is taken out of the
kernel by exact key on the way (`the SA pair to … is nothing to resume:
removing it`, §12), so no start of this boot finds it. The no-target
bring-up decides the same after its resume attempt: a resumable pair whose attempt failed keeps the fatal
and the restart; a dead pair is written off, taken out of the kernel (§12)
and the daemon idles (`RequestIdle`). A reader that did not answer
(`PCSCF_DISCOVERY=failed`) is no fact: the daemon exits 1 instead — before
the bus name with `no P-CSCF: discovery failed … — exiting for systemd
restart`, from the no-target bring-up with `bring-up FAILED: no P-CSCF for a
fresh registration: …` and `engine fatal: … — exiting for systemd restart` —
and the unit's restart runs discovery again.
- Never creates or connects a bearer while the modem is `disabled` — its
radio is off (airplane mode): a modem still disabled after the wait, or
disabled when an attempt is due, makes the script exit non-zero (`modem …
disabled … (radio off): not connecting`) and systemd retries. A bearer
connect on a disabled modem fails with `no-service` and leaves
ModemManager's state `enabled` with the modem in low power; switching the
radio back on then no longer brings it up (dev phone 2026-10-06: not
registered after 150 s, against 5 s without the attempt; a ModemManager
disable/enable recovered it).
- Connects by **3GPP profile index** (`IMS_PROFILE_ID`, default: looked up by
APN in `qmicli --wds-get-profile-list=3gpp`), not by APN string: a
profile-indexed request is exempt from the modem's attach-family forcing
that refuses an APN-string IPv6 request when the attach PDN was granted
IPv4-only (Odido, O2 UK, Swisscom). An empty profile list is the modem still
coming up; a list without the APN is final only once the modem is
registered (the list can be partial while it enables). A leftover
APN-string bearer is never reused and is deleted when connecting by index.
- Sets `accept_ra_defrtr=0` on the netdev before link-up: the PDN's router
advertisement must never make the ims PDN the phone's IPv6 default route (on
IPv4-only wifi every AAAA-first client would send its SYNs into the IMS
walled garden and hang). The RA's prefix and MTU still apply. An RA default
route already present (an imsd restart) is flushed.
- Sets the PDN MTU ModemManager reports on the link (1280 on the FP6), adds
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
the address and waits out DAD.
- Resolves the P-CSCF: `PCSCF` when set (one IPv6 address; it wins and
discovery is skipped — a list or a non-IPv6 value exits non-zero), else
`imsd-pcscf --mux <the netdev's rmnet mux_id> [--profile <index>]` reads
the list the network put in the PDN's PCO from the modem (§13; the mux id
from `ip -d link show <netdev>`), not for `IMS_IP_TYPE=ipv4`, which the bring-up does not support (it
configures only the PDN's IPv6 address). The usable
addresses — IPv6; IPv4 ones and names are logged and dropped — are handed
over (at most 8) and the first, the daemon's fresh target, gets a host route
(`dev <netdev>`, no gateway — a raw-IP netdev without header_ops); imsd
routes the P-CSCF it uses itself as well — the fresh target before a fresh
registration, the resumed SA peer once its SAs are confirmed (§7.2 steps 3 and
4) —, as each call's media peer is. Nothing usable is logged with
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
its cause (`nothing usable (…)`), is not the script's failure, and is told
apart in the hand-off: `PCSCF_DISCOVERY=none` when the reader found no IPv6
P-CSCF in the PCO (its exit 2: the list absent, empty or unreadable —
`tests/Qmi` pins the tolerant read) — a fact of this PDN: a registration of imsd's own that
can be resumed is resumed and stays up (a fresh registration later restarts
the daemon for want of a target, and that start runs discovery again), a
bring-up that did not resume ends in the daemon's clean idle (`idle: no
P-CSCF …`, the idle rule above) or, after a resumable pair's failed
attempt, in the fatal and the restart; `PCSCF_DISCOVERY=failed` when the
reader did not answer (an error, a timeout, nothing printed) — no fact: a
daemon with nothing to resume exits 1 and the unit's restart runs
discovery again. Anything else on
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
the IMS network (its DNS servers, a traceroute target) needs its own host
route (`ip -6 route add <addr> dev qmapmux0.0`). imsd installs a host route
per media peer itself, and one to the P-CSCF it registers to or resumes on
(§7.2 step 3).
- Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in
nftables when a `inet filter` table exists (pmOS's default INPUT chain drops
inbound on `qmapmux*`, which silently killed every network-initiated
request after ESP decapsulation). Best-effort.
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
- Writes `/run/imsd.env`: `DEV=<netdev>`, `LOCAL=<PDN address>`,
`PCSCF_DISCOVERED=<list>` (comma-separated in the modem's order; empty when
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
nothing was discovered or `PCSCF` is set) and `PCSCF_DISCOVERY=found|none|failed|skipped`
(how the list came to be: the PCO's answer, the reader's failure, or
discovery not run — `PCSCF=` set, `IMS_IP_TYPE=ipv4`, no rmnet mux id), and
logs the line it wrote. imsd
registers on the address the network assigned, not on the kernel's
temporary SLAAC address from the RA, which the netdev lists first and
retires after about two days (the SAs and the Contact would go with it).
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
The script never reads `PCSCF_DISCOVERED` from its environment: the unit
hands it the previous run's file, and a stale discovered value must not
pass for the admin's `PCSCF=`.
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
Its own settings (read from the environment, else from the file `IMSD_ENV`
names, `/etc/imsd.env` by default, for manual runs): `IMS_APN` (default
`ims`), `IMS_IP_TYPE` (`ipv6`), `IMS_PROFILE_ID` (empty = look up; `none` =
connect by APN string), `IMS_REG_TIMEOUT` (300) and `PCSCF`; from the
environment only, `IMSD_PCSCF` (the reader's path, default
`/usr/libexec/imsd-pcscf` — the mock bench's seam) and `IMSD_ENV` (the
configuration file's path — the bench's seam too).
### 7.4 Registration recovery (`RegRecovery`, `Imsd-Engine.cppm`)
What the daemon does when the registration stops working after bring-up, as
a pure reducer the shell feeds with the clock and the network's answers. Two
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
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 REGISTER with a final other than the expected one: from 300
up on the unprotected one, whose 401 is the challenge, anything but 200 on
the protected one) and **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 and
RFC 5626 have the UE register anew). Before this reducer existed the daemon
looped reconnect + re-REGISTER on one 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).
The policy, with its constants:
| Step | Rule |
|---|---|
| Reconnect | Through the existing SA, then refresh. Back-off 5 s → 5 min (`ReconnectDelayMin`/`Max`). |
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
| Escalate to a fresh registration | After one refused refresh whose `Retry-After` is at least a second (the network's considered answer; a `Retry-After` of 0 or one that does not parse counts as none; O2 2026-10-08: the refresh refused 503 with Retry-After 414 s, the refresh after that wait 503 with Retry-After 772 s) or a 401 (re-authentication, which imsd answers with a fresh registration rather than over temporary SAs), after two refused refreshes without one (`RefusalsBeforeFresh`), or two reconnect cycles that end without an answer (`SilentCyclesBeforeFresh`) while the modem reports packet service (`PacketServiceUp`: ModemManager's registration and packet-service state, as `ims-pdn-up.sh` reads them). A cycle that fails during a coverage gap is the phone's silence, not the network's: not counted, retried on the local-refusal pace, so a radio gap across a refresh does not escalate into a fresh registration, which KPN refuses while the registration is live and drops it for (§13). New Call-ID, new challenge, new SA pair (`registering fresh (recovery attempt N)`; with no fresh target the daemon restarts instead, §7.2 step 3). |
| A refused or throttled fresh attempt | Waits on the **throttle schedule**: 21 min, growing ×1.5 to 60 (`ThrottleMinutesMin`/`Max`). KPN refuses a fresh SA pair on an address whose registration is live (§13), and drops that registration the moment an initial REGISTER arrives. |
| A silent fresh attempt | Backs off 30 s → 10 min (`FreshDelayMin`/`Max`). |
| `Retry-After` | Always wins over a shorter wait, honoured up to an hour (`RetryAfterMax`, the schedule's longest wait); a longer one is cut to an hour and the refusal heard again then. On a refused fresh attempt it replaces the schedule's wait, shorter or longer, never below 30 s (`FreshDelayMin`). |
| A 403 | A refusal like the others: never hammered (the schedule), never abandoned (a SIM that is unbarred registers within the hour). |
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
| The old registration is gone | A 403 once a fresh registration is due, a fresh attempt that got past the SA setup, a binding the network terminated (reg-event), an older address that left the netdev, or a bring-up whose resume failed while a fresh registration follows (with no fresh target a resumable pair whose attempt failed is kept for the restart; a pair nothing can resume — given up or written off by the engine, its address off the link, no state file, a file another attempt left behind, left untried by `RESUME=0` — is written off and taken out of the kernel, §7.2 step 1): no reconnect over the old SA from then on. Otherwise the existing SA is still retried on the reconnect back-off while a fresh registration is deferred. |
| A `Dial` while unregistered | Brings the next recovery step forward (the reconnect, or the fresh attempt once it is due) — never a wait the network imposed (a `Retry-After`, or the schedule after a refused or throttled fresh attempt). imsd-ofonod refuses a `Dial` then and imsd-dialerd lists no modem. |
| Success | Resets everything. |
The network's waits are **holds**, and a restart of imsd (an upgrade restarts
it) does not cut them short: `PersistHolds` writes them to the holds file
(`HOLDS_FILE`, default `/run/imsd.holds`) as JSON — `pdn` (the PDN's own
address), `boot` (the boot id), `written_at` (seconds of uptime), `hold`,
`fresh_hold`, `throttle_minutes` (the schedule's place), `old_gone` (the old
registration is gone: a 403 once a fresh registration is due, an attempt past
the SA setup, a binding the network terminated, an older address that left
the netdev, and every bring-up that goes on to register fresh — its resume
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
failed or there was nothing to resume; a bring-up with no fresh target
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
writes `old_gone` when nothing can be resumed — a pair nothing can resume: its UE
address left the link, no state file, a file another attempt left behind, written
off before this start, given up or written off by the engine during the
attempt, left untried by `RESUME=0` — never for a pair kept for the restart,
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
§7.2 step 1); the reducer's refusal count travels too (`refusals`): a resume
refused without a Retry-After at a start with no fresh target is one
refusal, the restart's is the second and `RefusalsBeforeFresh` gives the
resume up — without it the restart loop would re-REGISTER on the same
Call-ID every `RestartSec`, the F16 shape — and
`LoadHolds` takes them
over at the next start for the same `pdn` and boot id, waiting them out
before the resume (a refresh's `Retry-After`) or before the fresh
registration (a fresh attempt's schedule or `Retry-After`, a terminated
binding's wait); with `old_gone` set the start does not resume over the old SAs at
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
all: with a fresh target it waits the fresh hold out and registers fresh,
with none it removes the pair and ends as §7.2 step 1 says — also after a start
during an outage, whose resume and fresh attempt both fail without an answer:
the next start then registers fresh while the network still holds the old
registration, which KPN refuses (§13; dev phone 2026-10-04: 21 min where
0.3.9's retried resume would have recovered at once). A file without the key
reads as not gone; the 0.3.9 hand-over (`imsd-handover.sh`) writes the key as
true, since the wait it hands over follows a throttled initial REGISTER, at
which KPN drops the old registration (§13). A reboot or another PDN address
starts clean. The schedule's place is kept
even after the holds run out, so a restart does not start it over at 21 min.
When nothing is held the file is removed — when it is a regular file;
`/dev/null` switches the knob off and anything that is not a regular file is
emptied, never unlinked. The recovery policy's waits and the holds are
measured on the boot clock (`CLOCK_BOOTTIME`), so a suspend counts; the
keepalive refresh gate, the CRLF keepalive and the reg-event retry and
renewal timers run on the steady clock and count awake seconds only.
The **reg-event subscription** (RFC 3680; the network's own view of the
binding) is one live dialog per registration, subscribed anew ahead of the
lifetime the S-CSCF granted. Refused or unanswered, it is retried after 60 s
doubling to 10 min (a `Retry-After` honoured up to an hour; a registration
refresh neither brings a scheduled retry forward nor starts its back-off
over — the back-off resets when a subscription starts afresh or is
accepted). When the S-CSCF ends it
for a reason that allows renewal (RFC 6665: `deactivated`, `timeout`,
`giveup`, `probation`) it is renewed; after any other reason — `noresource`,
`rejected`, a reason-less end — the next registration refresh subscribes anew. An
earlier subscription's NOTIFY is still read for the binding's state, but its
end is not ours to renew (KPN closes a registration's previous subscription
with `noresource` when a new one replaces it). A NOTIFY that **terminates the
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
binding** triggers a fresh registration where there is a fresh target (with none a
running daemon restarts, and a bring-up whose resume the NOTIFY ended idles
— exit 1 instead when `PCSCF_DISCOVERY=failed` — §7.2 steps 1 and 3) — at
once, or after the network's `retry-after` (up to an hour); after the call, when one is up (one exception:
a REGISTER wait that still gets its 200 after such a NOTIFY, a call having
kept the flow, leaves that registration standing). After
`rejected` and `unregistered` it waits on the refusal schedule, a wait no
`Dial` cuts short: the binding is gone, so nothing is retried meanwhile. One
termination counts once, however many NOTIFYs report it (a repeated
`probation` report may extend its hold from its own `retry-after`). The schedule is
shell code in `main.cpp`, not a reducer (§3); its hardware coverage per arm
is in §5.
A registration resumed on an older address (a pre-0.3.10 temporary one)
moves to the PDN's own address in place: with its next fresh registration, or
when the older address leaves the netdev first, which takes that registration
with it.
### 7.5 Upgrades and the 0.3.9 hand-over
An apk upgrade (or an `apk add` of a package whose daemon an `apk del` left
running) restarts `imsd` and `imsd-ofonod` once the transaction is done: the
packages' `post-upgrade`/`post-install` scripts mark the units
`needs-restart` (`systemctl set-property … Markers=+needs-restart`, after a
`daemon-reload` when the unit is not-found because its file was just
reinstalled) and pmOS's systemd trigger runs `enqueue-marked`. A call in
progress ends. The restart resumes the registration over its existing SAs,
or goes on waiting out a hold the network had set (§7.4) — or, with an
upper-case, zero-padded or uncompressed `PCSCF=` (accepted until 0.3.12),
stops at bring-up with the spelling to use (§7.2 step 3). A stopped unit
keeps no marker and `enqueue-marked` leaves it stopped (dev phone, systemd
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
262~rc3, 2026-10-03) — so a phone that never had `/etc/imsd.env` (the unit
never started before 1.0.0) keeps its stopped unit through the upgrade and
starts at its next boot, registering where §7.3's rule lets it — unless `imsd-ofonod`
runs there (the Phosh image, `imsd-ofono`): the upgrade restarts that daemon,
and its `Wants=imsd.service` starts imsd at once (`enqueue-marked` queues the
marked unit's try-restart, systemd collapses it to a restart for a running
unit, and a restart job pulls in a start job for each `Wants=` — systemd
262~rc3, `job_type_collapse` and `transaction_add_job_and_dependencies`; dev
phone 2026-10-11: a stopped imsd was started in the same second an
`imsd-ofonod` restart began, two seconds after it had been stopped, and
resumed) — without waiting for it:
since 1.0.0 `imsd-ofonod.service` has no `After=imsd.service` (that restart
would otherwise wait behind the ims PDN bring-up — the whole `ExecStartPre`, up to `TimeoutStartSec=900`), so GNOME
Calls' backend starts at once and republishes when imsd's name appears (`OnImsdAppeared`; dev
phone 2026-10-12, bench 30).
A daemon older than 0.3.10 kept its bring-up throttle wait in memory only, so
the restart registered fresh at once instead of waiting the schedule out.
For that one hop: `imsd.post-upgrade` records the running
daemon's `InvocationID` in `/run/imsd.handover` (when no holds file exists);
at the next start `imsd-handover.sh` reads that finished invocation's last
bring-up line from the journal and, when it is the throttle's (`bring-up
deferred: fresh-SA throttle active (spi-s=0) — … next fresh registration in
N min`; the old daemon's other deferrals, after a 401 it could not use, are
not handed over), ages it on the monotonic clock (`__MONOTONIC_TIMESTAMP` against
`/proc/timer_list`; the phone's wall clock can read 1970 until its first NTP
sync, and the monotonic clock stops in a suspend, so the wait can only come
out long, never short of the network's window), and writes what is left to
the holds file as 0.3.10 keeps it — for the address this start registers on
when that is the address the wait was earned on (the old daemon logged its
`LOCAL=` once at start); on another address — the normal hop, 0.3.9 on the
kernel's temporary address and 0.3.10 on the PDN's own, which holds no
registration (§13) — only the verdict that the old registration is gone is
written and the new start registers fresh at once (dev phone 2026-10-03:
accepted, registered 16 s after 0.3.9's refused attempt) —
`LOCAL` as systemd hands it to the unit's commands (the `/etc/imsd.env` pin
if there is one, else what `ims-pdn-up.sh` just wrote), unquoted the way imsd
itself gets it. Reading at the start rather than at the upgrade means a wait
that ended in between — the old daemon making another attempt — is
read as it ended. When the old address cannot be read the wait is handed
over anyway; when the wait cannot be timed, or this start has no `LOCAL`, the
journal says it was not handed over.
### 7.6 Calls and the media plane
`CallMachine` owns one call's SIP dialog, outgoing or incoming, and never
touches a socket, a clock or a subprocess: each event returns `Action`s for
the shell (send this on the client flow, answer that request on the channel
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
it came in on, start/stop the media leg, hand it the tones a key press
sent, emit this D-Bus state change, arm this deadline). States and reasons are the frozen D-Bus vocabulary:
`dialing` / `ringing` / `incoming` / `active` / `terminated`, with reasons
`outgoing` / `incoming` / `accepted` / `local-hangup` / `remote-hangup` /
`refused-or-busy` / `error`.
- Outgoing: INVITE with the SDP offer (AMR-WB + AMR + telephone-event by
default; `CODECS` restricts and orders; `PRECOND=1` adds IR.92
preconditions), reliable 18x with PRACK, 2xx ACK and media start, CANCEL
before answer, BYE after. Busy and error finals end the call with
`refused-or-busy` or `error`.
- Incoming: an INVITE on a protected-port listener is validated before it is
announced to the UI; 100 and 180 (reliable when the caller requires
100rel); `Accept` sends the 200 with the SDP answer at the offer's own
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
payload types and octet-align mode, its telephone-event echoed when the
offer's m-line lists one at the codec's clock; a remote CANCEL gets 200 + 487; an
unusable offer 488 naming what was offered; ring timeout 480. A landline
gateway's G.711-only or AMR-NB offer rings and is answered at its own
payload types.
- Emergency: a number `IsEmergency` classifies (112, 911, plus
`EMERGENCY_NUMBERS`; SIM `EF_ECC` is not read yet) is tried as
`urn:service:sos` first, with one fallback to a plain INVITE of the digits;
a plain INVITE answered `380 Alternative Service` with an emergency body is
re-attempted as `urn:service:sos`.
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
- DTMF (`SendDtmf`): the tones go out as RFC 4733 telephone-event packets
on the call's RTP stream, at the payload type the SDP negotiated for the
codec's clock (`MediaLeg::dtmfPt`: the answer's for an outgoing call, the
offer's for an incoming one). The
engine's `OnDtmf` lets them through on an active call that negotiated
telephone-event and drops them otherwise, with a journal line that says
why (before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or a string past
`DtmfMaxTones` (128) — then
the whole string, never part of what was pressed; letters are
upper-cased, `a` and `A` are one event). There
is no second method: no SIP INFO, and no tone played into the microphone
path. The shell writes one `dtmf
<tones>` line per `SendDtmf` down the leg's control pipe.
- Incoming calls reach imsd only when the network's T-ADS routes the call to
the IMS domain; the REGISTER Contact advertises the MMTEL ICSI and
`+sip.instance` for that. When T-ADS still picks CS, no INVITE arrives —
a network/modem-integration matter, not a daemon defect.
`imsd-media` is one process per call: `imsd-media <local-ip> <rtp-port>
<remote-ip> <remote-port> <pt> <secs> <out-base>`. It binds the advertised
RTP port (`RTP_PORT`, default 50004), sends uplink frames from `pw-record`
through the encoder, captures the downlink, and with `PLAY=1` reconstructs
the media clock from RTP timestamps so `pw-play` stays real-time-paced
through far-end DTX silence. Codecs: AMR-WB (16 kHz, the default), AMR
narrowband and G.711 PCMA/PCMU (8 kHz, what a PSTN gateway offers); AMR
frames ride RFC 4867 payloads, octet-aligned or bandwidth-efficient
(`OCTET_ALIGN`, mirrored from the SDP). The AMR codecs are dlopen'd
(`libvo-amrwbenc` + `libopencore-amrwb` for WB, `libopencore-amrnb` for NB);
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
G.711 is a table. DTMF (RFC 4733 telephone-events; `DTMF_PT` names their
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
payload type, unset = none; imsd sets it from the SDP and writes the lines
down the control pipe, §7.1): each `dtmf
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
<tones>` line on its stdin queues the tones, and a tone takes seven 20 ms
slots of the uplink in place of the audio frames — the event's five
(100 ms at -10 dBm0: the marker bit on the first packet, the duration
growing a slot at a time, the end bit on the fifth packet) and two in
which the end packet goes out again (RFC 4733 §2.5.1.4) — then three
slots of audio before the next tone; the audio's own sender carries the
events — per captured frame (a silence tick without a mic) the event
packet goes in the frame's place, one packet per slot — so events and
audio share the stream's sequence and timestamp space (RFC 4733 §2.1), the
clock stays the capture's, and the audio after an event is stamped as if
it had run through it; a line of more than 4096 bytes is
dropped through its end and the next line goes. **Exit
code 3 means the downlink dried up** for
`MEDIA_TIMEOUT` seconds (default 6.0) — the far-end-hangup signal, which on
carriers whose BYE never reaches our SAs is the reliable teardown trigger.
Per-call stats land in `OUT_DIR` (default the state dir); `RTP_DUMP=1`
captures the raw downlink next to them.
**Audio session.** Call audio goes through the PipeWire of one account.
imsd resolves it for each call from logind: the user session active on
seat0 (`loginctl show-seat seat0`; a greeter does not count) when it has a
`/run/user/<uid>/pipewire-0` socket, and passes it to `imsd-media` as
`AUDIO_USER`. `AUDIO_USER` set in imsd's own environment pins an account
instead; one that is not an account falls back to logind. imsd as packaged
(root, on the system bus) logs the resolution at start and when a call's
media starts (`audio session: …`), `imsd-media` once per call. An `imsd-media` started without the variable at all (by hand, or
by an imsd older than 0.3.10 while an upgrade replaces it) tries the
account `user`, its old default.
### 7.7 Runtime files
| Path | Written by | Content |
|---|---|---|
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
| `/etc/imsd.env` | the admin | configuration (§10), none required since 1.0.0 where discovery finds a P-CSCF (§7.3): `PCSCF=` overrides what discovery found, and names the P-CSCF where discovery is skipped or finds none; the file's existence changes nothing. Before 1.0.0 the apk's drop-in `ConditionPathExists=/etc/imsd.env` made it the switch for the whole unit (§9.2) |
| `/run/imsd.env` | `ims-pdn-up.sh` | `DEV=`, `LOCAL=` of the connected PDN, `PCSCF_DISCOVERED=` the IPv6 addresses of the PCO's P-CSCF list in the modem's order, at most eight (IPv4 entries and names are dropped; empty when the PCO gave no IPv6 P-CSCF, when discovery did not run or failed, or when `PCSCF=` is set), `PCSCF_DISCOVERY=found|none|failed|skipped` (how the list came to be: the PCO's answer, the reader's failure, or discovery not run); or `IMSD_IDLE=no-sim` / `no-ims-pdn` alone when the bring-up found nothing to do (§7.3; the daemon decides the dead end after the PDN is up itself) |
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
| `/run/imsd.holds` (`HOLDS_FILE`) | imsd, `imsd-handover.sh` | the network's holds, the schedule's place and the reducer's refusal count (§7.4) |
| `/run/imsd.handover` | `imsd.post-upgrade` | the InvocationID of the daemon an upgrade replaces, written when no holds file exists (§7.5); consumed by `imsd-handover.sh` |
| `/run/imsd-ofonod.started` | `imsd-ofono-restart-calls.sh` | marker: imsd-ofonod has started once since boot |
| `/var/lib/imsd/imsreg.state` (`STATE_FILE`) | imsd | the registration context for a warm resume |
| `/var/lib/imsd/*.stats`, `*.rtp`, `*.raw` | imsd, imsd-media | per-call media stats, raw RTP (`RTP_DUMP`), SIP dumps (`DUMP_SIP`; mode 0600 — they carry the IMSI/MSISDN) |
| `$XDG_STATE_HOME/imsd/` (else `~/.local/state/imsd/`), `$XDG_RUNTIME_DIR/imsd.holds` (else `/tmp/imsd.holds`) | imsd | the same files for a non-root (`--session`) run |
### 7.8 Journal vocabulary
Lines the operator (and the scripts) look for, all prefixed `imsd: `:
- `LOCAL=… P-CSCF=…`, `SIP transport: …`, `SIM slot=… IMSI=… domain=…` at start.
- `RESUME via existing SA (cseq N)`, `REGISTERED (resumed + true-refreshed)`,
`FRESH registration`, `Security-Server: …`, `REGISTERED (fresh)`.
- `bring-up deferred: …; next: fresh registration in N s` — one line; `in N
min` from a minute (the pre-0.3.10 form `bring-up deferred: … next fresh
registration in N min` is what `imsd-handover.sh` reads from an older
daemon's journal), `a hold the network set before this start stands (…)`,
`the refusal schedule stands at N min from before this start: …`.
- `…; next: fresh registration in N s` (`in N min` from a minute, `now`),
`registering fresh (recovery attempt N)`,
`fresh registration deferred/refused/failed: …`, `reconnected + re-registered`,
`recovered by a fresh registration`.
- `the registration's SAs are on X, not the PDN's own address Y: resuming
there until the next fresh registration`, `the fresh registration moves
from X to the PDN's own address Y`, `back on X: the old registration stands
until the next fresh attempt`, `X has left DEV: the registration on it is
gone; staying on Y; next: …`, `X is no longer on DEV: the registration on
it is gone; on the PDN's own address Y now; next: …`.
- `pcscf: configured X` / `pcscf: configured X; discovery gave Y, Z (ignored:
PCSCF= wins)` / `pcscf: discovered Y, Z (using Y)` at start, `pcscf:
PCSCF_DISCOVERED entry '…' is not an IPv6 address; ignored`; `pcscf:
resuming on X, the registration's SA peer; the next fresh registration
goes to Y` (+ ` (the peer is not in today's list)`, or `… (no P-CSCF
configured or discovered)`), `the fresh registration moves from P-CSCF X
to Y`, `back to P-CSCF X: the old registration stands until the next fresh
attempt`, `no reg state file; resume falls back to fresh` and `the state file names
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another
attempt's; resume falls back to fresh` (` to X` only when the file records a
P-CSCF — a pre-1.0.0 file does not; with no fresh target both end in
`nothing can resume it: the pair is removed` instead), `no P-CSCF for a
fresh registration: none configured (PCSCF=) and none discovered
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
(PCSCF_DISCOVERED empty — see the ims-pdn-up lines)`.
- `PCSCF='…' is not written as ip prints it; use PCSCF=…` (under `bring-up
FAILED:`) — one accepted spelling of the setting (§7.2 step 3); the
bring-up script's `ims-pdn-up: PCSCF '…': write the bare address, as ip
prints it (no brackets)` (its own prefix: a script line) is the same rule
for a bracketed value.
- `engine fatal: … — exiting for systemd restart`.
- `reg-event SUBSCRIBE sent`, and the binding's state from NOTIFY.
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
- `the SA pair to X is nothing to resume: removing it` — a bring-up with no
fresh target that found a pair nothing can resume (§7.2 step 1; `N xfrm
entries of imsd's own with no pair to resume: removing them` when the
removal finds entries of imsd's own but no pair `SaPeer` can name —
policy-less states on the UE's address, a lone or disagreeing fingerprinted
policy —, §12); a
resumable pair whose attempt failed is kept for the restart (`resume
failed: … — the restart tries the resume again`, `resume connect failed;
the restart tries the resume again`, `state file transport differs from
SIP_TRANSPORT; the restart tries the resume again` — the last at every
restart while the forced transport stands; with a fresh target each ends in
`the next registration is a fresh one`); a registration the engine's rule
wrote off — a 403 once a fresh registration is due, a binding a NOTIFY
terminated under the re-REGISTER wait — is removed instead (`resume failed:
… — written off by the network: the pair is removed`; with a fresh target
`… — written off by the network: the next registration is a fresh one`),
and so is a resume
the engine gives up with no fresh target — a 401, a refusal with a
Retry-After, the second refusal, the count travelling in the holds (`resume
failed: … — the resume is given up (the engine's rule): the pair is
removed`).
- `audio session: …` at start and at each call's media start; `CODECS override active: …`.
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
- `media <codec> pt=N octet=true|false -> [X]:P, telephone-event pt N|none` at a
call's media start; `call X: DTMF '…' -> media leg (telephone-event pt
N)` for tones the engine lets through, `call X: DTMF '…' dropped: the call is <state>` /
`… dropped: no telephone-event negotiated for this call` / `… dropped:
'c' is not a tone` / `call X: DTMF of N tones dropped: more than 128` for
tones not sent, `SendDtmf(X, '…') ignored: no such call`; `DTMF '…' not
sent: no control pipe to the media leg` on a call whose control pipe
could not be made or that has no media leg, `DTMF '…' not sent: <error>`
when the control pipe's write fails (`EPIPE`: the leg died under the
action; `EAGAIN`: it is alive, stopped reading, its pipe full),
`DTMF of N tones not sent: longer than the control pipe's atomic
write` (a line the engine's bound should have stopped), `media control
pipe: …; this call has no DTMF` when the pipe could not be made.
- A refused REGISTER or SUBSCRIBE is logged with its status line and
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
any `Retry-After` / `Warning` / `Reason` / `Server` headers;
`re-REGISTER challenged: 401 …; re-authentication asked, the next
registration is a fresh one` for a challenged refresh; `state file holds
no challenge answer (pre-0.3.11): the refresh carries an empty digest`
at a resume of a registration made before 0.3.11 — every reregistration
of it carries the empty digest until its next fresh registration, and
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
the line repeats at each later resume of it; `Security-Verify from the
state file (1 mechanism)` / `(N mechanisms)`, `state file holds no Security-Server (a
pre-0.3.12 file, or a list the file cannot hold): Security-Verify
rebuilt from the SA as one mechanism` or `state file's Security-Server
names another SA pair than the one in the kernel (spi-s N vs M):
Security-Verify rebuilt from the SA as one mechanism` at every resume.
- `connect: no answer from the P-CSCF N times (…)` when the protected
connect series ends unanswered.
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
- `idle: no SIM — nothing to do; systemctl start imsd when that changes`
(or `idle: no ims PDN from the network — …` from `IMSD_IDLE`; `idle: no
P-CSCF — none configured (PCSCF=), none discovered (see the ims-pdn-up
lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to name one —
…` the daemon's own, before the bus name or
from the no-target bring-up), then exit 0; `no P-CSCF: discovery failed
(see the ims-pdn-up lines) and nothing to resume — exiting for systemd
restart`, exit 1, when the reader did not answer (`PCSCF_DISCOVERY=failed`).
From the scripts: `ims-pdn-up: …` — among them `no SIM (sim-missing): not
bringing the ims PDN up — systemctl start imsd once one is in`, `bearer
connect refused 10 times in a row by profile index with packet service and a
persistent cause (last: …): no ims PDN for this SIM here — systemctl start
imsd to try again`, `wrote
/run/imsd.env: IMSD_IDLE=…`, `P-CSCF discovered: a, b
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
(pco-requested 1, profile-pcscf-via-pco 1)`, `P-CSCF discovery also gave v4 …
(not usable on the IPv6-only ims PDN)`, `P-CSCF discovery: nothing usable
(…)`, `P-CSCF X configured: discovery skipped`, `host route to P-CSCF X dev
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
Y` (the configured address or the first discovered), `wrote
/run/imsd.env: DEV=… LOCAL=… PCSCF_DISCOVERED=… PCSCF_DISCOVERY=…` —,
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
`imsd-handover: …`, `imsd-ofono-restart-calls: …`.
## 8. D-Bus ABI and desktop integration
### 8.1 `net.catcrafts.IMS1` (frozen)
Object `/net/catcrafts/IMS1`, interface `net.catcrafts.IMS1`. The ABI is
frozen — the two translation daemons depend on it, and a new desktop needs a
new translation daemon, not a change here.
| Member | Signature |
|---|---|
| `Dial(number)` | `s → s` (callUni) |
| `HangUp(callUni)` | `s` |
| `Accept(callUni)` | `s` |
| `SendDtmf(callUni, tones)` | `ss` |
| `GetCalls()` | `→ aa{sv}` |
| `GetStatus()` | `→ a{sv}`: `registered` (b), `local` (s), `pcscf` (s), `expiry` (x), `cseq` (x), `ppi` (s) |
| `CallAdded` | `s a{sv}` |
| `CallStateChanged` | `s s s` (callUni, state, reason) |
| `CallDeleted` | `s` |
| `RegistrationChanged` | `b` |
`Dial`, `HangUp`, `Accept` and `SendDtmf` are gated by polkit (§7.1); a
refused caller gets `org.freedesktop.DBus.Error.AccessDenied`, and polkit not
answering is logged with the sender. A translation daemon that runs as root
on the system bus must gate its own callers through `imsd-bus`, or it is a
bypass of `net.catcrafts.IMS1.Voice`.
### 8.2 Plasma Mobile (`imsd-dialerd`)
A drop-in for plasma-dialer's `modem-daemon`, owning the same session-bus
names and objects (`org.kde.telephony.CallUtils` at
`/org/kde/telephony/CallUtils/tel/mm`, likewise `DeviceUtils` and
`UssdUtils`; the `/tel/mm` suffix is hard-coded in plasma-dialer's QML plugin
and kde-telephony-daemon — keep it). plasma-dialer, kde-telephony-daemon and
spacebar run unmodified. ABI details verified against plasma-dialer 6.7.3 are
in the header of `implementations/dialerd.cpp` (`CallData` marshalled as a
struct wrapping `a{sv}`, struct-wrapped int32 enums in MMCallState numbering,
an empty callUni meaning the active call, `callsChanged` with the full vector
on every add/delete). It lists no modem while imsd is unregistered.
### 8.3 GNOME Calls, Phosh, GNOME Mobile (`imsd-ofonod`)
Presents imsd as an oFono modem: `org.ofono` with a `Manager` at `/`, one
`Modem` + `VoiceCallManager` at `/imsd`, and a `VoiceCall` object per live
call — what GNOME Calls' bundled `ofono` provider plugin drives; Calls, Phosh
and GNOME Shell run unmodified. `org.ofono` is the seam because it is the
only one a third party can implement: `org.gnome.Calls.Call` is what Calls
*exports* so a shell can observe and control a call that already exists
(Accept, Hangup, SendDtmf, Silence — no Dial), and Phosh consumes it from
Calls — its call UI needs nothing extra once Calls has a working origin;
Calls' own backends are libpeas provider plugins (`mm`, `ofono`, `sip`,
`dummy`) against private headers no distro installs.
Consumer-shaped details verified against calls 50.0 are in the header of
`implementations/ofonod.cpp`. Verified on a Fairphone 6 (postmarketOS,
dbus-broker) with GNOME Calls 50.0: outgoing and incoming calls with audio
both ways, answered and hung up through `org.gnome.Calls.Call` — the surface
Phosh drives. The answered calls predate the polkit gate (§5).
Setup: `apk add imsd-ofono` enables `imsd-ofonod.service` by preset and ships
a gschema override setting `org.gnome.Calls autoload-plugins` to `['ofono']`
(the per-user equivalent is `gsettings set org.gnome.Calls autoload-plugins
"['ofono']"`). Drop `mm` rather than adding `ofono` alongside it: the
ModemManager provider would offer the modem's CS voice path as a second
origin, and on a phone whose voice path is imsd that origin carries no audio.
Never run it beside a real `ofonod` (one name, one owner;
the unit has `Conflicts=ofono.service` and the daemon exits if it loses the
name). Running `imsd-dialerd` at the same time is fine (different bus,
different consumer). One dbus-broker
detail is load-bearing and lives in `packaging/org.ofono.conf`: dbus-broker
checks a broadcast against the sender's send policy for every name the
receiver owns, so root (imsd) must be allowed `send_destination="org.ofono"`
or imsd's call signals never reach the daemon.
Known gaps, all inherited rather than introduced:
- USSD is refused with `org.ofono.Error.NotSupported` and not advertised in
the modem's `Interfaces`: the IMS stack has no USSD path.
- No hold, multiparty, transfer or call waiting (`org.ofono.Error.NotImplemented`):
imsd is a single-call stack.
- Per-call caller-ID withholding is refused rather than silently ignored:
imsd cannot honour a CLIR request, and failing is safer than dialling with
the caller ID exposed.
- Phosh's emergency-call screen lists nothing: `imsd-ofonod` publishes
`org.ofono.VoiceCallManager.EmergencyNumbers` (112, 911, plus
`EMERGENCY_NUMBERS`), oFono's standard place for it, but Calls' ofono
provider never reads it: it makes no `GetProperties` call on the VoiceCallManager
and returns NULL for the origin's `emergency-numbers`, which is what
`org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from (the
ModemManager provider fills its equivalent via
`mm_sim_dup_emergency_numbers()`). An upstream fix would close this.
Dialling an emergency number from the dialpad is unaffected — that reaches
imsd's own classifier.
- Restarting `imsd-ofonod` restarts GNOME Calls with it: when the modem's
`VoiceCallManager` interface goes away and comes back, Calls 50.0 adds a
new origin but leaves the old origin's `CallAdded` handler connected, so
after one such cycle every call is added twice, and after a few the stale handler runs on
freed memory and Calls dies with SIGBUS in `g_hash_table_lookup` (measured
on the FP6). Calls-side; an upstream fix belongs in
`plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own
restarts and registration drops from triggering it, the
`VoiceCallManager` interface is listed permanently (`Online`
tracks registration; `Dial` fails with "imsd is not registered" / "imsd is
not running"), and only this daemon's own restart cycles the origin — after
which Calls dials nothing until it restarts, so the unit's `ExecStartPost`
(`imsd-ofono-restart-calls.sh`) restarts the Calls daemon of every user
running one. The first start since boot leaves a running Calls alone.
Development without a phone or root (crafter-build's binaries run inside the
sandbox: §4.1):
```sh
./bin/imsd-*/imsd --session & # imsd's ABI on the session bus
./bin/imsd-ofonod-*/imsd-ofonod --session
busctl --user call org.ofono / org.ofono.Manager GetModems
```
## 9. Packaging, CI and releases
### 9.1 The APKBUILD
`packaging/APKBUILD.binary` is **the** packaging since 2026-09-02: it wraps
a crafter-build binary cross-compiled per §4.3 (bundled by
`packaging/make-bin-tarball.sh` together with the units, scripts and
policies) into the apks CI publishes. `pkgver` is taken from the `Version`
constant in `implementations/main.cpp` by `build-package.sh`.
Subpackages: `imsd-ofono` (the GNOME Calls backend, opt-in) and
`imsd-systemd` (the unit files; `install_if` systemd — ordered after
`imsd-ofono` because `systemd()` amoves all of `usr/lib/systemd/system`).
There is no from-source recipe: a build needs crafter-build (§4.1).
It declares `provides="81voltd=$pkgver-r$pkgrel"`: this satisfies
`soc-qcom-modem`'s dependency and excludes the real package, because 81voltd
serves the modem's own ims-PDN requests, races imsd for the PDN and flaps it
with a new prefix every ~2.5 min — two IMS stacks cannot share one PDN.
`depends` lists `modemmanager`, the AMR codec libraries, `pipewire-tools` and
`polkit` (the gate fails closed without it). There is no OpenRC service: the
unit's sequencing is only tested under systemd.
### 9.2 What the package installs and enables
`imsd.service` is enabled by preset (`80-imsd.preset`, plus the
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
`multi-user.target.wants` link), so VoLTE survives reboots without a manual
`systemctl enable`. Since 1.0.0 there is no condition on `/etc/imsd.env`:
the P-CSCF comes from the PDN's PCO, so the unit starts on every phone with
the package — installing it and inserting a SIM is the opt-in; the file holds
overrides only — and a phone with nothing to do (no SIM, no ims PDN from the
network, no P-CSCF in the PCO and nothing to resume) exits cleanly and stays
inactive (§7.3)
rather than failing and restarting. `systemctl mask --now imsd` opts out: a
plain `disable` removes only the [Install] links, and `imsd-ofonod`'s static
`Wants=imsd.service` (the Phosh image) pulls a disabled imsd back in at every
boot and at its every restart (dev phone 2026-10-11, the fp6 journal's
`2026-10-11-0400-bench-kpn` capture: an `imsd-ofonod` restart started a
stopped and, in the control run, a disabled imsd); the mask
held through an `imsd-ofonod` restart and a reboot on the dev phone
(2026-10-11). `systemctl unmask imsd` opts back in. `imsd-ofonod` does not
wait for imsd (no `After=imsd.service` since 1.0.0, §7.5).
Until 0.3.12 the apk shipped a drop-in `ConditionPathExists=/etc/imsd.env`;
the upgrade removes it, and an upgraded phone that never had the file starts
imsd at its next boot — or during the upgrade itself where `imsd-ofonod`
runs (§7.5) — and registers where §7.3's rule lets it. `imsd-ofonod.service` is enabled the same way by
`imsd-ofono`.
The preset, the `multi-user.target.wants` link and the drop-in no longer
shipped are the apk's doing (`APKBUILD.binary`'s `package()`); the idle, the
`Wants=` pull-in and the opt-out are the bring-up script's and the daemon's,
systemd's and the admin's.
The main package hides kde-telephony's `modem-daemon` autostart for accounts
created after it is installed (`/etc/skel/.config/autostart/org.kde.modem.daemon.desktop`)
because `imsd-dialerd` owns those names.
### 9.3 Install scripts
`imsd.post-upgrade`, `imsd-ofono.post-upgrade` and `imsd-systemd.post-upgrade`
mark their units `needs-restart` (§7.5); `imsd.post-upgrade` also records the
old invocation for the 0.3.9 hand-over. Each `*.post-install` is a symlink to
its `post-upgrade`: `apk del` leaves the daemon running, so a later `apk add`
is an upgrade for that daemon and apk runs post-install for it. A first
install finds no unit active and marks nothing. `imsd-systemd` marks
`imsd.service` too, since it ships the unit file and apk installs it after
`imsd`.
### 9.4 CI and what a push means
`.forgejo/workflows/package.yml` runs on every push to `main` (and on
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
dispatch): on the `flatpak` runner label (a privileged runner registered to
this repository — bubblewrap inside the job container needs the privilege), in an
`alpine:edge` container, `packaging/build-package.sh` installs the
Crafter.Build Flatpak (`net.catcrafts.CrafterBuild//stable` from
flatpak.catcrafts.net — the branch Crafter.Build publishes from its release
tags, where `beta` follows every push to its master; the build log names its
commit) and llvm/abuild, builds
the sysroot, cross-compiles the five products, runs the suite natively inside
packaging, ci: build the apks with the Crafter.Build Flatpak Crafter.Build's CI publishes a Flatpak and nothing else since 2026-10-02: the rolling `latest` release's `crafter-build-linux-x86_64-musl-v2.tar.gz` that build-package.sh fetched is gone (404 since 2026-10-03), so a push of a release would fail at `fetch crafter-build` and publish nothing. Install the Flatpak (`net.catcrafts.CrafterBuild//stable` from flatpak.catcrafts.net) in the job container and run it as crafter-build. bubblewrap inside the container needs a privileged runner, so the job runs on the `flatpak` label. The sysroot stays Alpine edge and gains llvm-runtimes, the package with the std module source of its own libc++ (libc++-dev ships none). make-sysroot.sh adds its list with --upgrade: run over a sysroot from before this change (libc++ 22.1.8, no std module source) a plain add installed llvm-runtimes 23.1.2 alone, and its std.cppm did not compile against the 22.1.8 headers (`'text_encoding' file not found`); with --upgrade libc++, libc++-dev and llvm-runtimes came out at 23.1.2 and it compiled. AGENTS.md §4.1, §4.3, §8.3 and §9.4 describe the route as it runs, and the README's Building section carries the install lines. Three comments in the files this touches said what is not so and are corrected: package.yml had fp6-img install the package by a pinned version and sha256 (it takes the newest in the registry), build-package.sh had fp6-img's image build run `make check` on the aarch64 binaries (it builds no imsd: no suite runs on them), and make-sysroot.sh's usage line lacked the --march without which the cross build fails on an x86 host. Tested: build-package.sh run end to end on this commit in a privileged alpine:edge container (podman) on the workstation.
2026-10-04 01:23:23 +02:00
the Flatpak, wraps the result with `APKBUILD.binary`, and the publish step
uploads the apks to the Forgejo Alpine registry
(`…/api/packages/Catcrafts/alpine/edge/fp6`) with `PACKAGE_TOKEN`. The
registry answers 409 for an already-published version and the step treats
that as "nothing to do", so **a push releases exactly when the `Version`
constant changes**. This is the only producer of the imsd apk: fp6-img
images install the published package from the registry, and
installed phones upgrade with plain `apk upgrade`. **A push to `main` is a
release to real users' phones.** A release is a range of commits ending in a
bump commit titled `imsd X.Y.Z` that changes only the `Version` constant.
## 10. Configuration reference
Everything is environment variables. The packaged unit reads
`/run/imsd.env` (from `ims-pdn-up.sh`) and then `/etc/imsd.env` (the user's,
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
wins on conflict; optional since 1.0.0). The variables are user
documentation: the tables are in the README.
### 10.1 imsd
The README's first table. The protected client/server ports are fixed at
45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also
requires updating the firewall rules `ims-pdn-up.sh` installs. `PCSCF` and
`PCSCF_DISCOVERED` are read at start (`FreshPcscfFromEnv`, for the start line
and again by the engine): the first is one address in RFC 5952 form (§7.2 step 3: another spelling is
refused at bring-up), and wins, the second a comma-separated IPv6 list whose
first entry `CanonicalIp6` accepts is the fresh target otherwise (an IPv4
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
address, a name, a literal with a zone or a dotted-quad tail is skipped — logged by the
engine when the daemon goes on to register, not by the idle before the bus
name). A resume goes to the kernel's SA
peer whatever they say (and routes it itself); beyond the resume's `pcscf:`
line, having a fresh target decides what a refused or failed resume
leads to — the fresh registration (a refusal without a Retry-After is not
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
counted) or, with none, the pair kept for the restart, removed, or the idle
(§7.2 step 1) — and a comma-separated `PCSCF=` stops the bring-up before the
resume.
### 10.2 imsd-media (per call, set through imsd's environment)
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
The README's second table. These are not configuration and are left out
there:
| Variable | Default | Meaning |
|---|---|---|
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
| `CODEC`, `OCTET_ALIGN`, `DTMF_PT` | *(from the SDP)* | set by imsd from the negotiated answer (`DTMF_PT`: the telephone-event payload type at the codec's clock, empty when none was negotiated); not configuration |
| `MIC_SRC`, `PCM_DUMP` | *(unset)* | test seams (§5) |
### 10.3 ims-pdn-up.sh and imsd-ofonod
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
`IMS_APN`, `IMS_IP_TYPE`, `IMS_PROFILE_ID`, `IMS_REG_TIMEOUT`, `PCSCF`,
`IMSD_PCSCF` and `IMSD_ENV` (the configuration file's path, a bench seam) for
the bring-up script (§7.3); `IMSD_IDLE` and `PCSCF_DISCOVERY` are the
script's word to the daemon, not settings. `imsd-ofonod` reads `EMERGENCY_NUMBERS` (from
`/etc/imsd.env` through its unit) and `IMSD_BUS=session|system` to override
only the imsd side of its bus choice.
## 11. Making changes
- **Commit messages.** Subject `area: what changes` — the area is the module
partition, program, script or `packaging`/`ci`/`tests`/`README` the change
lives in (several joined by commas), the rest a short lowercase clause
with no trailing period, imperative or declarative. The body says why —
the field incident or the spec clause — and, where the change alters
behaviour, in one sentence how it was tested. A version bump is a commit
titled `imsd X.Y.Z` that changes only the `Version` constant and has no
body.
- **Versioning.** `MAJOR.MINOR.PATCH`: the patch number counts releases,
fixes and features alike; the minor number moves when a release changes
what a user can rely on the daemon for (0.3 added emergency calling), not
with the size of a release; the major number moves when a release changes
what an installed phone does without being asked, and it is not an ABI
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM With the P-CSCF coming from the PDN's PCO, the configuration file has no required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in goes: the unit starts on every phone with the package, and VoLTE is on by default. Installing the package and inserting a SIM is the opt-in; the file holds overrides only, and its existence changes nothing. By the versioning rule (AGENTS §11, the first commit of this series) that is what moves the major number: the release is 1.0.0, and §11 now records it. What used to be the file's job — keeping a phone nobody configured out of a failing unit — is now a rule on facts, the same for every phone: a modem in the failed state with a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and exit 0; a profile-indexed connect series the network refuses ten times in a row — each a persistent 3GPP cause in ModemManager's typed error (not subscribed, unknown APN, barred, not authorised in the PLMN, authentication failed, the service option not supported, the wrong PDN type) with packet service at the time; counted only while the modem reads registered, connecting or connected with packet service not detached — stricter than the daemon's PacketServiceUp; any other read, a timeout or a transient cause starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no rmnet mux id) or no hand-off at all (a manual run) — and no registration of imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the holds' old_gone say what can be resumed; a pair the holds wrote off is taken out of the kernel there) and at the no-target bring-up, where a pair nothing can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure leaves alone, so the unit stays inactive until the next boot, a systemctl start or a start of imsd-ofonod where that runs (its Wants starts an idle imsd again). What is not a fact keeps the retry: no coverage, a SIM still waiting for its PIN, a modem without an ims profile (the APN-string connect is tried), an APN-string series (the modem's own wall), a resumable pair whose attempt failed (the fatal), and a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery again). A phone that never had the file keeps its stopped unit through the upgrade and starts at its next boot, registering where the rule above lets it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd in through its Wants (systemd queues the marked unit's try-restart, collapses it to a restart and starts what the unit Wants in the same transaction: job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the opt-out is systemctl mask --now imsd, since a plain disable is undone by that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd starting on every phone, GNOME Calls' backend would otherwise wait behind the ims PDN bring-up at every boot and at its marked restart at an upgrade, for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name appears (OnImsdAppeared). The configuration file's path is the IMSD_ENV seam the mock bench uses. AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files, 2026-10-13; the 72 of 2026-10-06 predate the Qmi files). Tested: crafter-build test, 8 passed; on the host build the daemon's idle exit for the script's keys and, with no pair, for every discovery verdict but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for failed; the script's arms on the mock bench; on the dev phone (KPN), in builds of this series: the daemon's idle at the no-target bring-up with the dead pair removed and before the bus name for a pair the holds had written off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart and the mask holding against it and a reboot, and imsd-ofonod starting at once with imsd stopped and republishing when imsd's name appeared.
2026-10-11 16:20:07 +02:00
change (§8.1: the ABI is frozen). 1.0.0 is the first to move it: the unit
starts on every phone with the package, with no file to write, and
registers where `PCSCF=` names the P-CSCF or, on the FP6's `qmapmux0.0`
(KPN, the one device and network it ran on), where the reader gets the
PCO's IPv6 list.
- **No AI attribution trailers.** Commits carry no `Assisted-by:`,
`Co-Authored-By:` or similar lines, whoever or whatever drafted them.
- **Signed commits.** Every commit is GPG-signed with the repository's
configured identity (`git config user.signingkey`, `commit.gpgsign`). Do
not override the identity from the environment.
- **One change, one commit; a fix goes into the commit that introduced the
problem** while a range is unpublished (rebase), never as a follow-up on
top. Every commit of a range builds and passes the eight suites on its own.
- **Keep `make-bin-tarball.sh`'s file list and `APKBUILD.binary`'s
`package()` in sync** when a packaged file is added.
- **The README and this file follow the code in the same commit.** A new
knob gets its row in the README's configuration tables; a new state its journal line in §7.8; a new file
under `/run` or `/var/lib` its row in §7.7.
- **Compatibility notes are kept where the mechanism lives**, not in a
changelog the repository does not have: the 0.3.9 hand-over is documented
in §7.5 because `imsd.post-upgrade` ships in every later package and the
path stays live for any direct upgrade from an older daemon.
- **Never commit captures or state** (`*.raw`, `*.rtp`, `*.stats`,
`imsreg.state`): they carry the subscriber's identity.
## 12. Security considerations
- imsd runs as root (xfrm, qmicli). Its attack surface on the bus is the
frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an
ungated write member, and do not add a root translation daemon that
forwards calls without gating its own callers through `imsd-bus`.
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every transport-mode reqid-1/2 ESP state running from such a policy's source to its destination or anchored on the UE's ims-PDN address, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
- imsd deletes by exact key what two rules select, whoever installed it. A
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer A resume compared PCSCF= against the kernel's SAs and, when they did not match, registered fresh without a word — a second initial REGISTER from an address whose registration is live; on KPN a fresh pair to the same P-CSCF meets the 21-minute refusal that costs the live registration (§13), what another P-CSCF does with it is unmeasured. The registration was made to whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair out first), so that is where a resume belongs: SaPeer reads it from the dir-out transport policies — the one address a reqid-1 and a reqid-2 policy both name, each with imsd's fixed protected port as its selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the P-CSCF, a lone policy is not ours and a VPN's beside the pair changes nothing — TryResume resumes there, routes it, and says so when PCSCF= differs; the next fresh registration moves to PCSCF= and moves back when it fails before the SA setup and the old registration still stands (its address still on the link), as it already did between addresses, and its fallback Route is the P-CSCF it registers at, set before the 200's Service-Route is looked for — an empty Service-Route value counts as none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live to record — to this or another P-CSCF — is recognised by its SA pair: the file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's (FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of resumed with the wrong dialog; the state file gains pcscf, named in that line. Both fresh paths — the bring-up's and recovery's — now set their target themselves: with no PCSCF=, a bring-up that did not resume fails with its own message, and since no fresh one follows: a resumable pair whose attempt failed stays for the restart to resume, a pair nothing can resume — one the engine's rule wrote off during the attempt among them, a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY terminated under the re-REGISTER wait, decided once after both catch arms by its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a refusal with a Retry-After, the second refusal — with no fresh target every refusal reaches the reducer and its count travels in the holds, so the restart's refusal is the second, while with a target only a Retry-After does, as before — a refusal without one is not counted and the fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the same Call-ID at every RestartSec, the F16 loop) — is removed so the next bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a registration can be resumed with no setting at all. Three journal lines of the fresh-target path change with it: `resume connect failed; falling back to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end in `the next registration is a fresh one`, and a resume the engine writes off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait) logs `resume failed: … — written off by the network: the next registration is a fresh one` (§7.8). `no reg state file; resume falls back to fresh` keeps its text with a fresh target and ends in `nothing can resume it: the pair is removed` without one; the new `the state file names another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …` line takes the same two tails. Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel through the holds); the resume on the SA peer with the fresh target absent in builds of this series on the dev phone (KPN), and the no-target bring-up there with a resume that failed on a transport mismatch (the pair kept, the restart resumed) and with the state file removed (the pair removed, the next bring-up found nothing); the two dead-resume lines' no-target tail — a missing file, a file with another pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs but another P-CSCF's address passes the check and the resume runs — `RESUME via existing SA`, the connect then failing in the namespace); on the dev phone (bench 41, the tip's build) a resume on a file without its pcscf key, the live pair's SPIs.
2026-10-11 15:58:49 +02:00
fresh registration's SA setup and the dead pair's teardown (`RemoveDeadPair`)
delete — `ip xfrm state delete src dst proto esp spi`, `ip xfrm policy
delete <selector> dir` — what two rules select (`OwnXfrmDeleteCommands`): a transport-mode policy with reqid 1 or 2 whose
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm A fresh registration's SA setup began with `ip xfrm state flush` and `ip xfrm policy flush`: every SA userspace had installed and every SPD policy went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies included. The setup now takes out what two rules select and leaves the rest; and the SA reader, which took the P-CSCF ports for a resume from any reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair alone in the kernel — takes them from the policies between our two addresses only. Whether imsd runs beside a live strongSwan or libreswan connection is unmeasured: the dev phone shows a tunnel policy and a tunnel state left in place (Tested below), no more. OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text the shell reads: every transport-mode policy with reqid 1 or 2 whose selector carries imsd's protected port on the UE side, by the selector ip xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction, and every transport-mode reqid-1/2 ESP state running from such a policy's source to its destination or anchored on the UE's ims-PDN address, by src, dst, proto esp and SPI — policy or not: a setup whose policy add failed leaves such states (the setup logs a failed add and goes on), and an ESP state another transport-mode user put on that address with reqid 1 or 2 goes with them (an AH or IPComp state is not selected: the rule asks for ESP). Every reader of the policy text — the deletes and the SA reader's port pass here — goes through one predicate (detail::OwnPolicy), written for whatever reads that text later. BuildSetupCommands loses its two flush lines and the shell runs the deletes right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two flushes with no caller, goes. A policy-based VPN's tunnel entries and another transport-mode user's pair on other ports and addresses stay. Residual: a transport-mode policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone or paired, between any addresses — carries imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the UE's ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state stays). Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair untouched; another transport-mode user's pair on other ports and addresses untouched, its reqid-1 ESP state on the UE's address deleted whoever made it, its AH state left; a policy-less state on the UE's address deleted, one between other addresses left; the reader's ports from our pair beside a foreign transport policy with ports); on the dev phone (KPN), in builds of this series, a fresh registration with a VPN's tunnel policy and state in the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
selector carries the protected port 45061/45062 on the UE side (imsd's
fingerprint), and a transport-mode reqid-1/2 ESP state running from such a
policy's source to its destination or anchored on the UE's ims-PDN address
(a state whose policy add failed goes too; an AH or IPComp state is not
selected). Never `ip xfrm state|policy flush`: a policy-based VPN's
(strongSwan's, libreswan's) SAs and policies, or another transport-mode
user's pair on other ports and addresses, stay. Before 1.0.0 the setup
flushed the host's whole xfrm state and policy. Residual: a transport-mode
policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone
or paired, between any addresses — goes, with the reqid-1/2 transport-mode
ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the
UE's ims-PDN address goes whoever made it (`tests/Ipsec` pins both).
- The bus policies deliberately let any local account *reach* both names;
authorization is polkit's, not the policy file's. Keep it that way: a
policy that names an account breaks the moment the account is called
something else.
- SIP dumps and the state file carry the IMSI, the MSISDN, SPIs and
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
addresses, and the state file (since 0.3.11) the last challenge answer —
imsd: a resumed registration echoes the 401's Security-Server list from the state file A warm resume rebuilt its Security-Verify from the kernel's SA: one mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329 §2.3.1 has the client mirror the server's whole list. The fresh registration kept the 401's list in memory, so a registration this process made fresh echoed it; after a restart, the resumed registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and its INVITEs carried the rebuilt entry until the next fresh registration. KPN's one-entry answer to the default offer is the rebuilt entry byte for byte; against a six-entry list (O2's) the mirror is broken, which the RFC has the server answer with a 494, and a refused resume registers fresh. The fresh registration now stores the joined Security-Server value in the state file (security_server, next to the challenge answer) and the resume takes it from there, logging the mechanism count, when the entry carrying its SPIs names the SA pair in the kernel: a file an attempt the process did not live to record left behind (its SAs installed, its 200 not written) names another. A file from before this change holds none. In both cases the resume falls back to the rebuilt entry, says which, and the registration's next state write stores that entry, which is what it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's recorded list and KPN's. A value with a quote, a backslash or a newline in it is written empty, which the resume treats as absent; RFC 3329's token syntax produces none of these, a quoted-string extension parameter could carry the first two. Tested on the dev phone (KPN) with the build of this tree: a resume from a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so do one from a file whose list names another SA pair and one whose list is empty; two keepalives at a 120-s interval; a fresh registration stores KPN's list byte-for-byte as the journal shows it received; a restart resumes with the list from the file; an outgoing call.
2026-10-10 23:42:01 +02:00
nonce, cnonce and response — and (since 0.3.12) the P-CSCF's
Security-Server list. Dumps are mode 0600 under the state dir, off by default
(`DUMP_SIP=0`), and never leave the phone in a commit.
- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
the default route and gives the P-CSCF a host route — the configured one,
or the first IPv6 address the modem reports from the PCO (the fresh target; at most eight
are handed over, IPv4 entries and names dropped); imsd adds one per call for
the media peer (§7.3) and one for the P-CSCF it registers to or resumes on
(§7.2 step 3). Nothing else is routed over it.
A change that routes anything else over it needs a reason and a bench.
- The fixed protected ports (45061/45062) are opened in nftables for
`qmapmux*` only, by the bring-up script.
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd With the netdev configured, the script runs imsd-pcscf against the netdev's rmnet mux id (and the ims profile index when it knows one) and hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in /run/imsd.env and gives the first — the daemon's fresh target — a host route (the SA peer of a registration imsd resumes is routed by imsd itself, as the configured address's arm already left it); IPv4 addresses and names are logged and dropped (the PDN is IPv6-only), at most eight are handed over, and nothing usable is logged with its cause and is not the script's failure: imsd resumes a registration of its own when it can, else says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused. The script does not read the variable from its environment: the unit hands it the previous run's file, and a stale discovered value must not pass for the admin's setting. The reader's path is the IMSD_PCSCF seam the mock bench uses. Tested: on the mock bench against this commit's script (the twelve 0.3.10 scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an address), and in builds of this series on the dev phone (KPN), whose PCO — two IPv6 addresses, nothing else — reaches the plain found arm; the configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal and the rc-0 arm on the mock only. On a tip build of this series (daemon 2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target this script had not routed — the reader stubbed through the IMSD_PCSCF seam to find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own route, and a resume stood with the peer's route removed first.
2026-10-11 16:16:46 +02:00
- **`imsd-pcscf` holds a QMI WDS client of its own on the modem, as root,
from `ims-pdn-up.sh`.** It binds to the bearer's mux data port and reads
the call's runtime settings; it never sends Start Network or Stop Network. Its only writes
are its own client's mux binding and IP family; the call stayed connected
and the registration stood after it (§13). One client socket, closed on
exit (the service lookup's own socket closes when the lookup ends).
## 13. Carrier and platform facts the code relies on
Observed on real networks and encoded in the policy; cite them when touching
the related code.
- **KPN's fresh-SA throttle** — the `bring-up deferred: fresh-SA throttle
active (spi-s=0)` case — refuses a fresh SA pair on an address while the
registration on that address is live, whatever its age or last refresh:
refused 11 s, 23 min and 2 h 58 min into a registration, 4 and 6.6 min
after its last refresh (2026-09-19, -20, -24, 2026-10-03). KPN drops that
registration the moment the initial REGISTER arrives, throttled or not,
and with it gone accepts a fresh pair there: 5 and 21 min after the
throttled attempt (2026-10-03). An address that holds no registration is
accepted while another address's is live (2026-09-26, 2026-10-03), and a
new prefix's PDN address was accepted 15 s after a throttled attempt on
the temporary one (the 0.3.9 → 0.3.10 hop, 2026-10-03). An address whose
registration another address's had replaced was refused 26 s after,
accepted 141 s and 10 min after (2026-10-03): how soon KPN accepts where a
registration has just gone is not measured more closely. The schedule's
21 min is the wait that has never been refused, not a measured minimum;
imsd resumes rather than registers fresh wherever it can, since on a live
registration's address the attempt is refused and costs the registration.
- **O2 UK, 2026-09-24:** a refresh refused 21 × 503 over 70 min, then 33
unanswered reconnect cycles; a fresh registration was accepted in one
second. The origin of `RegRecovery`. On 2026-10-08, under 0.3.10 (the
first release to log a Retry-After), a refresh was refused 503 with
`Retry-After` 414 s and the refresh after that wait 503 with `Retry-After`
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
772 s: the origin of the `Retry-After` escalation. Every refresh of the
pre-0.3.11 shape — an initial registration's empty digest and the in-use
SPIs in Security-Client — on record from O2 was refused with 503: 21 on
2026-09-24 under 0.3.9 and the same loop again that afternoon on a
stationary phone, two on 2026-10-08 under 0.3.10, and the user never saw
one succeed, while
the fresh registrations that followed were accepted: the origin of the
5.1.1.4.2 refresh shape. KPN accepts both shapes without a challenge.
- **KPN closes a registration's previous reg-event subscription with
`noresource`** when a new one replaces it: that end is not renewed.
- **Modem attach-family forcing** (Odido, O2 UK, Swisscom): an APN-string
IPv6 bearer request is refused when the attach PDN was granted IPv4-only;
a profile-indexed request is not.
media: RFC 4733 telephone-event uplink, the tones from `dtmf <tones>` lines on stdin The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an IMS network it travels as RFC 4733 telephone-event packets on the call's RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in place of the audio frames — the event's five (100 ms at -10 dBm0: the marker bit on the first packet, the duration growing a slot at a time, the end bit on the fifth packet) and two in which the end packet goes out again (§2.5.1.4) — then three slots of audio before the next tone. Events and audio share the stream's sequence and timestamp space (§2.1), and one sender keeps it: the mic thread hands each captured frame to the DTMF state, which under one lock sends either the event packet the slot carries or the frame (the main loop's silence tick does the same without a mic, and so does the latch burst), so one packet leaves per slot, the clock stays the capture's, and no frame can land inside an event whichever thread holds the slot. The payload type comes from DTMF_PT in the environment; unset means no DTMF and a dtmf line is dropped with a message; a line of more than 4096 bytes is dropped through its end and the next line goes. Nothing in imsd supplies the lines or the variable at this commit; a hand run or the loopback does here, and a hand run behind a job-control terminal is not stopped by pending input (the reader blocks SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin. The stats sidecar gains tx_dtmf and dtmf_pt. Tested: --selftest, and the fp6 journal's utilities/media_loopback.py --dtmf arm on a host build of this commit's tree (journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the two overflow, the no-mic and the early-tones runs beside it) and its ttyin.py pty test of the background hand run (journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's dtmf benches ran in builds of this series.
2026-10-14 15:05:24 +02:00
- **pw-record delivers 20 ms frames in bursts** — of two on an AMR-WB
call (1.8 frames per burst on average, the bursts ~37 ms apart; the dev
phone, 2026-10-15/16: of the uplink's inter-packet gaps 42–45 % are
under 4 ms and 50–52 % are 30–40 ms, 3–8 % lie between and under 1 %
above — the fp6 journal's `2026-10-16-dtmf-bench-14/uplink-cadence.txt`
over benches 4, 5, 13 and 14, counted by the `cadence.py` beside it),
of three or four on a narrowband one (3.3 on average, ~68 ms apart;
70 % under 4 ms, 28 % over 40 ms — `2026-10-16-dtmf-bench-17-heard/uplink-cadence-narrowband.txt`,
bench 17's two calls; the burst sizes of all six legs in `burst-sizes.txt`
beside it). A sender on a clock of its own cannot count the frames a
140 ms window holds, so the events ride the frames' own slots (§7.6),
and an event's packets leave in those bursts: the audio's own spacing,
which RFC 4733 §2.5.1.2 calls the natural interval for event updates
(its 50 ms recommendation is for a spacing of the sender's own).
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf reached imsd and went nowhere. The SDP parser reads the telephone-event payload type at the chosen codec's clock into the answer, among the m-line's formats only — an rtpmap the line does not list is not negotiated, and nothing is sent with a format the answer does not carry (RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733 §2.4.1), an offer with two codec clocks lists telephone-event at each, and the name is matched as every encoding name is, case-insensitively, with the clock whole. The media leg carries it, and imsd-media gets it as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The SDP answer imsd sends to an inbound offer carries telephone-event by the same rule, where it echoed any telephone-event rtpmap with a lower-case name and a clock prefix before: an rtpmap off the offer's m-line is no longer echoed, an upper-case name now is. The decision is the engine's: OnDtmf lets the tones through on an active call that negotiated telephone-event (the answer's number on a call we placed, the offer's on one we answered) and drops them otherwise with a line that says why: before the answer, after the call, no telephone-event at the codec's clock in the SDP, a character that is no tone or more than 128 tones (then the whole string, never part of what was pressed); letters are upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf, whole or not at all: the bound keeps the line within one atomic pipe write, the write end is non-blocking so that a leg that stopped reading fails the write instead of holding the engine thread, and a longer line is refused; SIGPIPE is ignored so that a write after the leg died fails with EPIPE instead of ending the daemon. The command queue's one argument is `arg` now — the number to dial, or the tones. The `media …` start line gains the telephone-event pt at its end. Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev phone's dtmf benches ran in builds of this series (journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-14 15:05:24 +02:00
- **DTMF on KPN (2026-10-14 to 16):** the answer to our offer echoes
telephone-event at our payload types — 98 at 16 kHz on an AMR-WB call to
voicemail, 100 at 8 kHz on a phone-to-phone call the network answered
narrowband AMR at 99 — and a stock phone's terminating INVITE offers
AMR-WB 107 with telephone-event 111; events of the sender's first shape
(100 ms, a packet every 20 ms, at least 100 ms apart) were heard on that
phone (bench 2 the first shape; bench 17 the one-sender shape — 28 keys
on two narrowband calls, every one heard there).
- **pmOS's default nftables** drops inbound on `qmapmux*`; without the
protected-port rule every network-initiated request (NOTIFY, MT INVITE)
dies after ESP decapsulation and MT calls fall back to CS.
- **The kernel's temporary SLAAC address** on the ims netdev is listed first
and retired after about two days; a registration bound to it dies with it.
- **systemd reads `EnvironmentFile=` per spawned command** (systemd 262~rc3
on the FP6, 2026-10-03: an `ExecStart` saw what its `ExecStartPre` had
just written), and unquotes a quoted value — which is why the hand-over script
takes `LOCAL` from its environment rather than parsing the files itself.
- **GNOME Calls 50.0** crashes after an oFono origin withdraw/re-add cycle
and dials nothing after `org.ofono` restarts until it restarts itself.
- **dbus-broker** checks broadcasts against the sender's send policy for
every name the receiver owns (the `org.ofono.conf` detail).
- **The phone's wall clock reads 1970 until the first NTP sync**; anything
that ages a journal line uses the monotonic or boot clock.
- **A WDS client bound to the bearer's mux data port, with IP family 6 set,
reads the call's runtime settings; one that sent neither the binding nor Set IP Family got
`OutOfCall`.** ModemManager owns the ims
bearer's call and never asks the modem for the P-CSCF fields; a second
client that does Bind Mux Data Port (the netdev's `rmnet mux_id`, endpoint
embedded/1 for the `ipa` driver — ModemManager's own binding), Set IP
Family 6 and Get Current Settings gets them: TLV 0x22 = 1, the IPv6 list
in TLV 0x2E (two addresses on KPN), no IPv4 list, an empty FQDN list. Get Current Settings from a client that sent neither
Bind Mux Data Port nor Set IP Family answered `OutOfCall` (the control run),
as did a client bound to a mux with no call (`--mux 7`). The bearer stayed
connected and the registration stood afterwards (dev phone,
2026-10-11; the fp6 journal's `2026-10-11-pcscf-pco-probe` capture).
libqmi does not model 0x2E and qmicli cannot print any of it, hence
`imsd-pcscf`.